From ab9c1646154510973f52c19f0747fcaa36d37c56 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Tue, 15 Sep 2026 09:13:48 -0300 Subject: [PATCH] =?UTF-8?q?feat(OPS110):=20sess=C3=B5es=20persistentes=20d?= =?UTF-8?q?e=20agente=20com=20attach/detach=20(Closes=20#1019)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/shell/worktree.sh | 44 +- .agents/skills/worktree-next-issue/SKILL.md | 4 +- .opencode/commands/worktree.md | 6 +- AGENTS-infra.md | 2 +- AGENTS.md | 2 +- docs/AGENT-OPS.md | 19 + docs/changelog/2026-09-15-ops110.md | 1 + .../ops110-attach-detach-runs-agent-impl.md | 174 +++++ .../ops110-f1-agent-session-hardening.md | 59 ++ knip.json | 2 +- package.json | 1 + scripts/agent-session.mjs | 678 ++++++++++++++++++ scripts/lib/agent-session.mjs | 323 +++++++++ scripts/lib/worktree.mjs | 91 ++- scripts/worktree.mjs | 156 ++-- tests/unit/agentSession.unit.spec.ts | 490 +++++++++++++ tests/unit/worktree.unit.spec.ts | 82 ++- 17 files changed, 1983 insertions(+), 151 deletions(-) create mode 100644 docs/changelog/2026-09-15-ops110.md create mode 100644 docs/plans/ops110-attach-detach-runs-agent-impl.md create mode 100644 docs/plans/ops110-f1-agent-session-hardening.md create mode 100644 scripts/agent-session.mjs create mode 100644 scripts/lib/agent-session.mjs create mode 100644 tests/unit/agentSession.unit.spec.ts diff --git a/.agents/shell/worktree.sh b/.agents/shell/worktree.sh index 617acbfb..5b1d2344 100644 --- a/.agents/shell/worktree.sh +++ b/.agents/shell/worktree.sh @@ -6,23 +6,28 @@ # na última linha, porque um processo filho (node) não consegue mudar o cwd do # shell que o chamou. # -# No terminal (esta função), o script também imprime a diretiva `launch -# opencode --model --auto [--prompt "…"]` (OPS26+OPS33+OPS93+OPS95): +# No terminal (esta função), o script também imprime a diretiva `launch node +# scripts/agent-session.mjs start --purpose= --dir= +# --model= [--issue=] [--argument=""]` (OPS26+OPS33+OPS93+OPS95+OPS110): # a função executa o cd e então a linha (tokenizada por xargs — honra as aspas -# do prompt, nunca eval), e o TUI do opencode abre no worktree — `next` com -# `/work-issue --issue ` já enviado (OPS33: a Issue claimada vai no prompt), -# `plan` com `/plan-issue` já enviado (OPS31), `fix` com `/bug-fix ` já -# enviado (a descrição do bug vai no prompt — aspas/barra-invertida do bag são -# removidas porque o xargs não honra escapes), `new` sem prompt (apenas -# conversar). Sem `--variant` (OPS95: o yargs do TUI rejeita o flag e só -# imprime o helper; variantes ficam na config global da máquina, via Ctrl+T). -# Modelo por invocação: `--cheap` (cheapestinference/deepseek-v4-flash), -# `--pro` (deepseek/deepseek-v4-pro), `--zen` (opencode-go/ox-alpha-free), -# `--go` (opencode-go/hy3), `--alibaba` (alibaba-token-plan/deepseek-v4-flash) no -# mapa fixo `WORKTREE_MODEL_MAP` (OPS93 menu; valores OPS95); sem flag o preset `deepseek/deepseek-flash` +# do argumento, nunca eval). Desde a OPS110 o launch não abre um TUI local dono +# da sessão: o `agent-session` sobe/reaproveita o `opencode serve` compartilhado, +# cria a sessão endereçável, dispara o driver destacado (que auto-submete +# `/work-issue --issue ` no `next`, `/plan-issue` no `plan`, `/bug-fix ` +# no `fix`; `new` abre só a sessão) e ANEXA o TUI (`opencode attach -s `) — +# fechar o terminal NÃO encerra o run; reentre com `pnpm agent:session attach` e +# encerre com `pnpm agent:session stop`. Sem `--variant` (OPS95: o yargs do TUI +# rejeita o flag e só imprime o helper; variantes ficam na config global da +# máquina, via Ctrl+T). Modelo por invocação: `--cheap` +# (cheapestinference/deepseek-v4-flash), `--pro` (deepseek/deepseek-v4-pro), +# `--zen` (opencode-go/ox-alpha-free), `--go` (opencode-go/hy3), `--alibaba` +# (alibaba-token-plan/deepseek-v4-flash), `--glm` (opencode-go/glm-5.3-flash), +# `--free` (openrouter/openrouter/free) no mapa fixo `WORKTREE_MODEL_MAP` +# (OPS93 menu; valores OPS95); sem flag o preset `deepseek/deepseek-flash` # permanece. Sem `exec` de propósito: ao sair do opencode, o terminal volta ao # shell dentro do worktree. Presets são constantes em scripts/lib/worktree.mjs; -# o marcador TEQO_WORKTREE_TERMINAL=1 é o que separa esta superfície da do comando +# o ciclo de vida das sessões vive em scripts/agent-session.mjs. O marcador +# TEQO_WORKTREE_TERMINAL=1 é o que separa esta superfície da do comando # `/worktree` do opencode (que nunca lança TUI). `--stay` suprime cd e launch. # # Instalação (uma linha no profile; requer bash ou zsh — usa BASH_SOURCE, arrays e here-strings): @@ -96,11 +101,12 @@ worktree() { # Diretiva `launch` (só existe quando TEQO_WORKTREE_TERMINAL=1 e sem --stay): o # script a gera a partir de constantes + dir slugificado (sem espaços), e desde - # o OPS33 o valor do `--prompt` carrega espaço e vem CITADO (`"/work-issue - # --issue "`) — o split por IFS=' ' não honra aspas, então a tokenização usa - # xargs (processa aspas duplas como um shell, NÃO é eval; o conteúdo é 100% - # gerado por constantes + número, sem input livre). Falha do launch (ex.: - # opencode fora do PATH) só avisa: o worktree já está pronto e utilizável. + # o OPS33 os valores com espaço vêm CITADOS (`--argument=""`; OPS110 — + # antes era o `--prompt "/work-issue --issue "`) — o split por IFS=' ' não + # honra aspas, então a tokenização usa xargs (processa aspas duplas como um + # shell, NÃO é eval; o conteúdo é 100% gerado por constantes + número, sem + # input livre). Falha do launch (ex.: node/opencode fora do PATH) só avisa: o + # worktree já está pronto e utilizável. local launch launch="$(printf '%s\n' "$out" | sed -n 's/^launch //p' | tail -n 1)" if [ -n "$launch" ]; then diff --git a/.agents/skills/worktree-next-issue/SKILL.md b/.agents/skills/worktree-next-issue/SKILL.md index 5e4f1567..0e80d72e 100644 --- a/.agents/skills/worktree-next-issue/SKILL.md +++ b/.agents/skills/worktree-next-issue/SKILL.md @@ -31,7 +31,7 @@ No opencode, isso é o comando **`/worktree next`** / **`/worktree kill`** (`.op **Default-go:** `next`, `plan` e `new` imprimem `cd ` na última linha **por padrão**; `--stay` suprime; modelos por invocação via `--cheap/--pro/--zen/--go/--alibaba/--glm/--free` (mapa fixo `WORKTREE_MODEL_MAP`, at-most-one; sem flag o preset permanece); `--go` remapeado em OPS93 para o provider OpenCode Go e em OPS95 para `opencode-go/hy3` (antes no-op do OPS24). No terminal interativo, use a função `worktree()` de **`.agents/shell/worktree.sh`** (uma linha de `source` no profile) para o `cd` ser aplicado de verdade no shell que te chamou — node não consegue mudar o cwd do shell pai. `kill` também imprime `cd
` no fim (a sessão nunca fica num diretório destruído). -**Launch do opencode (OPS26 + OPS33 + OPS31 + OPS93 + OPS95):** só a função shell marca `TEQO_WORKTREE_TERMINAL=1`; com o marcador, `next`/`plan`/`new` imprimem também a diretiva `launch opencode --model --auto [--prompt "/work-issue --issue "]` **antes** do `cd`, e a função a executa depois do cd — `next` abre o TUI com `/work-issue --issue ` já enviado (a Issue claimada vai no prompt; a skill lê o resto do GitHub); `plan` abre com `/plan-issue` já enviado (OPS31: o TUI já cai no fluxo de planejamento); `new` abre sem `--prompt` ("apenas conversar"). Sem `--variant` na diretiva (OPS95: o yargs do TUI rejeita o flag desconhecido e só imprime o helper — variantes ficam na config global do opencode, selecionáveis via Ctrl+T; `opencode run --variant max` segue sendo a via CLI para runs headless). Modelo por invocação via `--cheap` (cheapestinference/deepseek-v4-flash), `--pro` (deepseek/deepseek-v4-pro), `--zen` (opencode-go/ox-alpha-free), `--go` (opencode-go/hy3), `--alibaba` (alibaba-token-plan/deepseek-v4-flash) — valores corrigidos no OPS95 — e `--glm` (opencode-go/glm-5.3-flash, OPS100), `--free` (openrouter/openrouter/free, OPS100); sem flag o preset `deepseek/deepseek-flash` permanece. Sem `exec`: sair do opencode volta ao shell no worktree. Presets e mapa são constantes em `scripts/lib/worktree.mjs`. **O comando `/worktree` do opencode (e qualquer automação sem o marcador) nunca lança o TUI.** +**Launch do opencode (OPS26 + OPS33 + OPS31 + OPS93 + OPS95 + OPS110):** só a função shell marca `TEQO_WORKTREE_TERMINAL=1`; com o marcador, `next`/`plan`/`new`/`fix` imprimem também a diretiva `launch node /scripts/agent-session.mjs start --purpose= --dir= --model [--issue ] [--argument ""]` **antes** do `cd`, e a função a executa depois do cd. Desde a OPS110 o launch não abre um TUI dono da sessão: o `agent-session` sobe/reaproveita o `opencode serve` compartilhado, cria a sessão endereçável, dispara o driver destacado (auto-submete `/work-issue --issue ` no `next`, `/plan-issue` no `plan`, `/bug-fix ` no `fix`; `new` abre só a sessão) e anexa o TUI (`opencode attach -s `) — fechar o terminal **não** encerra o run; reentre com `pnpm agent:session attach` e encerre com `pnpm agent:session stop` (ver a seção "Sessões persistentes" de `docs/AGENT-OPS.md`). Sem `--variant` na diretiva (OPS95: o yargs do TUI rejeita o flag desconhecido e só imprime o helper — variantes ficam na config global do opencode, selecionáveis via Ctrl+T; `opencode run --variant max` segue sendo a via CLI para runs headless). Modelo por invocação via `--cheap` (cheapestinference/deepseek-v4-flash), `--pro` (deepseek/deepseek-v4-pro), `--zen` (opencode-go/ox-alpha-free), `--go` (opencode-go/hy3), `--alibaba` (alibaba-token-plan/deepseek-v4-flash) — valores corrigidos no OPS95 — e `--glm` (opencode-go/glm-5.3-flash, OPS100), `--free` (openrouter/openrouter/free, OPS100); sem flag o preset `deepseek/deepseek-flash` permanece. Sem `exec`: sair do opencode volta ao shell no worktree. Presets e mapa são constantes em `scripts/lib/worktree.mjs`; o ciclo de vida das sessões (serve/start/attach/stop/list) vive em `scripts/agent-session.mjs`. **O comando `/worktree` do opencode (e qualquer automação sem o marcador) nunca lança o TUI.** ## Fluxo quando invocado como skill (agentes que não têm opencode) @@ -45,7 +45,7 @@ No opencode, isso é o comando **`/worktree next`** / **`/worktree kill`** (`.op - **Branch `-`**: `code` = `id` do frontmatter; `slug` = título pt-BR via `src/lib/slug.ts` (acentos fora, não-alfanumérico → hífen); truncamento só no slug, nunca no código; valida com `git check-ref-format --allow-onelevel`. - **Base `origin/main`** (com `git fetch` antes); dir em `~/.cursor/worktrees/teqo/`. - **Provisiona o ambiente isolado do worktree** (determinístico, ver "Ambiente provisionado"): dev server na porta `3100+slot` e bancos próprios `teqo_wt` / `teqo_wt_test` no container compartilhado (`-p teqo`), com migrations aplicadas — agentes em paralelo não disputam porta 3000 nem o `teqo_test`. `--no-migrate` pula as migrations (bancos criados, envs escritos). -- **`kill`**: recusa destruir o worktree principal (main); recusa worktree sujo sem `--force`; remove e apaga o branch; remove os bancos gerados do worktree (best-effort). +- **`kill`**: recusa destruir o worktree principal (main); recusa worktree sujo sem `--force`; **encerra a sessão persistente do run (OPS110: abort + fim do driver; estado/lock removidos — sem sessão órfã)**; remove e apaga o branch; remove os bancos gerados do worktree (best-effort). - **NUNCA usa `--force` no `add`**, não inventa código de Issue, e nunca desfaz claim (reverter `in-progress` → `ready` é outra entrega). ## Ambiente provisionado (leia a saída do script) diff --git a/.opencode/commands/worktree.md b/.opencode/commands/worktree.md index e47ac866..4c855ae4 100644 --- a/.opencode/commands/worktree.md +++ b/.opencode/commands/worktree.md @@ -7,9 +7,9 @@ Rode o script determinístico abaixo (não improvise a lógica) e reporte o resu - `next [--issue N] [--stay] [--cheap|--pro|--zen|--go|--alibaba|--glm|--free]`: **claima** a próxima Issue claimável da fila (mesma fila/ordem e lock otimista de `pnpm agent:claim`) e cria (ou reutiliza) o worktree dela. Claim falhou → reporte o motivo — não há worktree. `--issue N` claima a Issue direcionada (`ready`) ou **reabre** uma já claimada (`in-progress`, sem re-claim). A saída avisa "já claimada — NÃO rodar `pnpm agent:claim`". Por padrão o script imprime `cd ` na última linha — aplique-o na sessão de shell atual (`cd `) e reporte o novo diretório. `--stay` suprime o `cd` (não troque de diretório) mas o claim ainda acontece. `--cheap/--pro/--zen/--go/--alibaba/--glm/--free` escolhe o modelo por invocação no mapa fixo `WORKTREE_MODEL_MAP` (cheap=cheapestinference/deepseek-v4-flash, pro=deepseek/deepseek-v4-pro, zen=opencode-go/ox-alpha-free, go=opencode-go/hy3, alibaba=alibaba-token-plan/deepseek-v4-flash, glm=opencode-go/glm-5.3-flash, free=openrouter/openrouter/free); at-most-one (múltiplas → erro). - `plan [bag] [--stay] [--cheap|--pro|--zen|--go|--alibaba|--glm|--free]`: cria um worktree de planejamento **diferente a cada invocação** (branch `plans/plan-issue-`, ou `plans/plan-issue-` sequencial sem bag; sufixo `-2`/`-3` em colisão) para rodar `/plan-issue` sem ocupar o main; nunca conflita com `next`. Não claima nada. Mesmo contrato de `cd` do `next`: aplique a última linha `cd ` por padrão; `--stay` suprime; flag de modelo como no `next`. - `new [bag] [--stay] [--cheap|--pro|--zen|--go|--alibaba|--glm|--free]`: cria um worktree **neutro** (sem função pré-definida — explorar ideia, conversar, planejar sem registrar nada) **diferente a cada invocação** (branch `work/`, ou `work/` sequencial sem bag; sufixo `-2`/`-3` em colisão); o prefixo minúsculo `work/…` nunca conflita com `next` nem `plan`. Não claima nada. Mesmo contrato de `cd` do `plan`: aplique a última linha `cd ` por padrão; `--stay` suprime; flag de modelo como no `next`. -- `fix [bag] [--stay] [--cheap|--pro|--zen|--go|--alibaba|--glm|--free]`: cria um worktree de **correção de bug** (skill `/bug-fix`) **diferente a cada invocação** (branch `fix/`, ou `fix/` sequencial sem bag; sufixo `-2`/`-3` em colisão); o prefixo minúsculo `fix/…` nunca conflita com `next`, `plan` nem `new`. **Não claima nem cria Issues** — o registro do bug é o post-mortem da skill. O bag é a descrição do bug: no terminal, o launch envia `--prompt "/bug-fix "` (a descrição chega com a skill). Mesmo contrato de `cd` do `plan`: aplique a última linha `cd ` por padrão; `--stay` suprime; flag de modelo como no `next`. -- `kill [--force]`: destrói o worktree em que você está e remove seus bancos. O script imprime `cd
` na última linha — aplique-o (`cd
`) para a sessão sair do diretório destruído. +- `fix [bag] [--stay] [--cheap|--pro|--zen|--go|--alibaba|--glm|--free]`: cria um worktree de **correção de bug** (skill `/bug-fix`) **diferente a cada invocação** (branch `fix/`, ou `fix/` sequencial sem bag; sufixo `-2`/`-3` em colisão); o prefixo minúsculo `fix/…` nunca conflita com `next`, `plan` nem `new`. **Não claima nem cria Issues** — o registro do bug é o post-mortem da skill. O bag é a descrição do bug: no terminal, o launch carrega `--argument=""` e o driver destacado auto-submete `/bug-fix ` (a descrição chega com a skill). Mesmo contrato de `cd` do `plan`: aplique a última linha `cd ` por padrão; `--stay` suprime; flag de modelo como no `next`. +- `kill [--force]`: destrói o worktree em que você está, encerra a sessão persistente do run (OPS110) e remove seus bancos. O script imprime `cd
` na última linha — aplique-o (`cd
`) para a sessão sair do diretório destruído. -> **Este comando roda SEM o marcador `TEQO_WORKTREE_TERMINAL`, então nunca recebe a diretiva `launch`** — só o `cd`. O launch do opencode (OPS26/OPS33+OPS93+OPS95, sem `--variant` — o TUI rejeita o flag) acontece apenas na função `worktree()` de `.agents/shell/worktree.sh` (terminal interativo); nunca abra um TUI aninhado a partir daqui. +> **Este comando roda SEM o marcador `TEQO_WORKTREE_TERMINAL`, então nunca recebe a diretiva `launch`** — só o `cd`. O launch (OPS26/OPS33+OPS93+OPS95+**OPS110**: delega ao `scripts/agent-session.mjs`, que sobe o servidor compartilhado, dispara o driver destacado e anexa o TUI — fechar o terminal não encerra o run) acontece apenas na função `worktree()` de `.agents/shell/worktree.sh` (terminal interativo); nunca abra um TUI aninhado a partir daqui. !`node scripts/worktree.mjs $ARGUMENTS` diff --git a/AGENTS-infra.md b/AGENTS-infra.md index 6fee88c9..dfbe7240 100644 --- a/AGENTS-infra.md +++ b/AGENTS-infra.md @@ -12,7 +12,7 @@ Production is self-hosted on the homeserver (`jorgesolla1313.com.br`, behind the - Local dev runs against a local Docker Postgres (`pnpm db:start`). To copy production content locally, use `pnpm db:pull` (`scripts/db-pull.mjs`): it only reads prod, only writes local, and excludes supporter PII (`contact`/`signature`/`subscription` row data). - `pnpm db:doctor` diagnoses local database connectivity for the dev AND test targets: it names who holds the port, points at exited/crash-looping containers, and flags Postgres containers sharing one data volume (two postmasters on one PGDATA corrupt the WAL — this destroyed the local cluster's checkpoint on 2026-07-25; the canonical setup is ONE compose container on 5432 hosting `teqo` + `teqo_test`). `pnpm dev` runs the same preflight via `guard-dev-db` and refuses to start with a named remedy instead of a later ECONNREFUSED. `db:start`/`db:stop` pin the compose project with `-p teqo`, so they always target that ONE container even from a worktree directory. **`db:start` is idempotent (OPS37):** with `teqo-postgres-1` up and healthy it is a no-op — a plain `compose up` from another worktree would recreate the shared container and kill every live dev/e2e connection (the compose file's relative `./docker/postgres/init` bind mount makes the config hash differ per worktree); apply a real config/image change on purpose with `pnpm db:start --force-recreate`. -- **Per-worktree environments (`pnpm worktree next`, 2026-08-08; default-go 2026-08-10):** parallel agents each get a deterministic isolated env — no fighting over port 3000, the shared `teqo_test`, or a second Postgres container. **`next` claims BEFORE creating the worktree (OPS33, 2026-08-10):** the same queue/order and optimistic lock as `pnpm agent:claim` (shared `claimIssue` in `scripts/lib/agent-github.mjs`); a failed claim prints the reason and stops with no orphan worktree; `--issue ` claims the directed issue (`ready`) or **reopens** an already-claimed one (`in-progress`, no re-claim, worktree reuse stays idempotent); the claim brief is printed and the output warns "já claimada — não rodar `pnpm agent:claim"` (`plan`/`new`/`kill` never touch Issues; the pool's coordinated `claimIssueForPool` is untouched). `worktree next [--issue N] [--stay]` provisions: a dev-server port `3100+slot` (slot = numeric part of the issue code, capped at 999 with hash fallback, bumped on collision among live worktrees), plus dedicated databases `teqo_wt` (dev) and `teqo_wt_test` (tests) created on the shared container with the full migration chain AND the minimal seed applied (`pnpm migrate` + `pnpm db:seed:minimal` per database — OPS28 parity: CI runs the same pair before e2e, so a worktree's `pnpm test:e2e` mirrors CI instead of failing on seed-dependent specs; the seed is idempotent and local-guarded, and `--no-migrate` skips both); writes `.env.local` + `.env.test.local` (gitignored, marker `# teqo worktree env (generated by pnpm worktree next)`; manual envs are never overwritten) and copies `PAYLOAD_SECRET`/`S3_*` (all-or-nothing)/VAPID from the main repo — plus the gitignored `.opencode/secrets/*` (e.g. `penpot-token`, referenced by the repo's `opencode.json` penpot/postgres MCPs — gated to subagents `penpot`/`postgres` via `permission`), without which opencode refuses to launch ("bad file reference" for the dangling `{file:…}`). **`next`/`plan`/`new` print `cd ` as the last line BY DEFAULT** — the caller applies it... (line truncated to 2000 chars) +- **Per-worktree environments (`pnpm worktree next`, 2026-08-08; default-go 2026-08-10):** parallel agents each get a deterministic isolated env — no fighting over port 3000, the shared `teqo_test`, or a second Postgres container. **`next` claims BEFORE creating the worktree (OPS33, 2026-08-10):** the same queue/order and optimistic lock as `pnpm agent:claim` (shared `claimIssue` in `scripts/lib/agent-github.mjs`); a failed claim prints the reason and stops with no orphan worktree; `--issue ` claims the directed issue (`ready`) or **reopens** an already-claimed one (`in-progress`, no re-claim, worktree reuse stays idempotent); the claim brief is printed and the output warns "já claimada — não rodar `pnpm agent:claim"` (`plan`/`new`/`kill` never touch Issues; the pool's coordinated `claimIssueForPool` is untouched). `worktree next [--issue N] [--stay]` provisions: a dev-server port `3100+slot` (slot = numeric part of the issue code, capped at 999 with hash fallback, bumped on collision among live worktrees), plus dedicated databases `teqo_wt` (dev) and `teqo_wt_test` (tests) created on the shared container with the full migration chain AND the minimal seed applied (`pnpm migrate` + `pnpm db:seed:minimal` per database — OPS28 parity: CI runs the same pair before e2e, so a worktree's `pnpm test:e2e` mirrors CI instead of failing on seed-dependent specs; the seed is idempotent and local-guarded, and `--no-migrate` skips both); writes `.env.local` + `.env.test.local` (gitignored, marker `# teqo worktree env (generated by pnpm worktree next)`; manual envs are never overwritten) and copies `PAYLOAD_SECRET`/`S3_*` (all-or-nothing)/VAPID from the main repo — plus the gitignored `.opencode/secrets/*` (e.g. `penpot-token`, referenced by the repo's `opencode.json` penpot/postgres MCPs — gated to subagents `penpot`/`postgres` via `permission`), without which opencode refuses to launch ("bad file reference" for the dangling `{file:…}`). **`next`/`plan`/`new` print `cd ` as the last line BY DEFAULT** — the caller applies it... (line truncated to 2000 chars) **OPS110 (sessões persistentes):** no terminal, `next`/`plan`/`new`/`fix` delegam o launch a `scripts/agent-session.mjs` — o run vive no `opencode serve` compartilhado (loopback `127.0.0.1:4199`, `TEQO_AGENT_SERVER_PORT`) com driver destacado (`--auto`) e TUI anexável (`opencode attach -s `); fechar o terminal não encerra (encerrar é `pnpm agent:session stop`; `worktree kill` encerra a sessão do worktree antes de removê-lo) e `list --json` é o endereçamento do OPS109; bind fora do loopback exige `OPENCODE_SERVER_PASSWORD` (fail-closed, sem `--mdns`). - To (repopulate news content, `pnpm db:seed:posts` fetches articles live from jorgesolla.com.br into the LOCAL db (same non-local `DATABASE_URL` guard as `pnpm dev`). It writes from a CLI process outside the deployed runtime, so after seeding — or after ANY direct-DB change — you must bust the production `posts` cache via `POST /api/revalidate`. See "Posts & Tags" in `AGENTS-public.md`. - To (re)populate the TSE 2022 election baseline, `pnpm db:seed:tse` downloads TSE open data and imports the Bahia scope into the LOCAL db (same guard). Since the 2026-07-23 hardening, elections-tab reads are cached under the `election-tse` tag — after re-seeding a database the deployed app reads, bust it via `POST /api/revalidate?tag=election-tse`. See "Election baseline data" in `AGENTS-campaign.md`. diff --git a/AGENTS.md b/AGENTS.md index 6e8cf442..59a3a66e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,4 +72,4 @@ src/ ## Per-worktree environments -`pnpm worktree next` provisions isolated envs per worktree: dev port `3100+slot`, databases `teqo_wt` / `teqo_wt_test` on the shared container with full migration chain + minimal seed, `.env.local` + `.env.test.local` (gitignored), copies of `PAYLOAD_SECRET`/`S3_*`/VAPID from main repo. Same provisioning for `plan [bag]`, `new [bag]` and `fix [bag]` worktrees (`plans/plan-issue-…`, `work/…`, `fix/…` namespaces — `fix` opens `/bug-fix` with the bug description delivered; no claim, no Issue). The skill `local-database` covers the full workflow. +`pnpm worktree next` provisions isolated envs per worktree: dev port `3100+slot`, databases `teqo_wt` / `teqo_wt_test` on the shared container with full migration chain + minimal seed, `.env.local` + `.env.test.local` (gitignored), copies of `PAYLOAD_SECRET`/`S3_*`/VAPID from main repo. Same provisioning for `plan [bag]`, `new [bag]` and `fix [bag]` worktrees (`plans/plan-issue-…`, `work/…`, `fix/…` namespaces — `fix` opens `/bug-fix` with the bug description delivered; no claim, no Issue). No terminal, o launch de `next`/`plan`/`new`/`fix` roda a sessão no `opencode serve` compartilhado e anexa o TUI (OPS110): fechar o terminal **não** encerra o run — reentre com `pnpm agent:session attach` e encerre com `pnpm agent:session stop` (estado/endereçamento em `~/.local/state/teqo/agent-sessions`; contrato do painel OPS109). The skill `local-database` covers the full workflow. diff --git a/docs/AGENT-OPS.md b/docs/AGENT-OPS.md index 91f8629f..4523ac1b 100644 --- a/docs/AGENT-OPS.md +++ b/docs/AGENT-OPS.md @@ -41,6 +41,7 @@ main → push dispara deploy.yml sozinho (ou workflow_dispatch manual) → prefl | `pnpm agent:claim [-- --dry-run]` | Fila ready+unblocked por prio → `in-progress` + brief (GitHub) | | `pnpm worktree next [--issue N] [--stay]` | Claim determinístico (mesma fila/lock do claim) → worktree + provisionamento | | `pnpm worktree fix [bug] [--stay]` | Worktree de correção de bug (`fix/`, skill `/bug-fix` — o launch envia a descrição do bug); não claima nem cria Issues | +| `pnpm agent:session [--json]` | Sessões persistentes do run (OPS110 — attach/detach; ver seção abaixo) | | `pnpm unblock:check` | Smoke do bootstrap do agente de desbloqueio no homeserver (OPS106; exige `GITHUB_TOKEN` + `AUTOMERGE_PAT`) | | `pnpm agent:register -- --id X --title T [--prio P1] [--depends A,B] [--plan docs/plans/x.md] [--model slug]` | Cria Issue no GitHub (`--plan` ⇒ `blocked` até `agent:ready`; sem plano ⇒ `ready`) | | `pnpm agent:ready -- --issue N[,N…]` | Pós-merge do plano: `blocked`→`ready` (só Issues com link `docs/plans/`) | @@ -57,6 +58,24 @@ Labels (GitHub): `ready|in-progress|blocked|done|in-prod`, `prio:*`, `kind:*`, ` **plan-issue (OPS17 + OPS18):** gate pós-overview fecha o lote antes de qualquer Issue/PR; register com `--plan` nasce `blocked` (não entra na fila do claim/pool); após o PR de planos mergear em `main` (`Related #N`), promote dual idempotente: (A) `pnpm agent:ready` na sessão; (B) Action `plan-issue-ready-on-main-merge.yml` lê `Related #N` e promove Issues ainda “aguardando plano” (`blocked` + link `docs/plans/`). Chores sem plano continuam nascendo `ready`. +## Sessões persistentes (attach/detach — OPS110) + +O launch do terminal (`worktree next|plan|new|fix`) **não abre mais um TUI dono da sessão**: ele chama `pnpm agent:session start …`, que sobe/reaproveita o `opencode serve` compartilhado, cria a sessão endereçável, dispara o **driver destacado** (`opencode run --attach … --auto --command `, que auto-aprova permissões enquanto vive) e **anexa** o TUI (`opencode attach -s `). Fechar o terminal/TUI desconecta — o run continua; encerrar é ato explícito. + +| Comando | Faz | +| -------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | +| `pnpm agent:session serve [--hostname=H] [--port=P]` | Sobe/reaproveita o servidor (loopback `127.0.0.1:4199`; `TEQO_AGENT_SERVER_HOST/PORT`) | +| `pnpm agent:session start --purpose= --dir= --model= [--issue=N] [--argument=""] [--new]` | Cria a sessão, dispara o driver (quando há skill) e anexa o TUI | +| `pnpm agent:session attach [--session=\|--branch=\|--issue=N]` | (Re)entra na sessão — default: branch do cwd; sobe o servidor se ele estiver fora | +| `pnpm agent:session stop [--session=\|--branch=\|--issue=N]` | Ato explícito de encerrar: abort via API + fim do driver + `stoppedAt` | +| `pnpm agent:session list [--json]` | Status dos runs (`working\|idle\|stopped\|unknown`); `--json` é o contrato do OPS109 | + +- **Endereçamento (OPS109):** `~/.local/state/teqo/agent-sessions/.json` (`TEQO_AGENT_SESSION_DIR` > `XDG_STATE_HOME`) guarda `version, code, issue, purpose, branch, dir, sessionID, url, model, driverPid, logPath, startedAt, stoppedAt`; `list --json` entrega `{version, server, sessions:[…status]}` — é a única fonte para "abrir a sessão da Issue". +- **Sair ≠ encerrar:** fechar o TUI/terminal não mata o run (verificado: SIGKILL no cliente e `tmux kill-session` não abortam; `attach` replayer histórico e streaming ao vivo). `Ctrl+C` no TUI limpa o input; sair é fechar o terminal ou o keybind `app_exit` (`ctrl+c,ctrl+d,q`). +- **Reuso:** `start` com driver vivo ou sessão `busy` → apenas anexa (nunca dois runs no mesmo branch); caso contrário cria sessão nova (`--new` força). `worktree kill` encerra a sessão do worktree antes de destruí-lo (estado/lock removidos — sem sessão órfã). +- **Segurança:** loopback por padrão; `--hostname` fora do loopback **exige** `OPENCODE_SERVER_PASSWORD` (fail-closed) e `--mdns` nunca é usado. A credencial vive só no ambiente — nunca no estado/log; loopback sem senha roda sem auth (aceito pelo produto: nada além de `127.0.0.1`). +- **Modos de falha:** morte do driver deixa pedidos de permissão pendurados até um `attach` responder (o `stop` sempre aborta via API, sem depender do driver); reboot derruba o run (a sessão/histórico persiste e pode ser reanexada); o daemon do servidor fica de pé (idle, loopback) — parada manual pelo `pid` em `agent-sessions/server.json`. + ## Contrato de PR 1. Schema → atualizar `db:seed:minimal` no mesmo PR (`needs:migration`). diff --git a/docs/changelog/2026-09-15-ops110.md b/docs/changelog/2026-09-15-ops110.md new file mode 100644 index 00000000..b5b15768 --- /dev/null +++ b/docs/changelog/2026-09-15-ops110.md @@ -0,0 +1 @@ +**OPS110 — runs longos de agente sobrevivem ao terminal: attach/detach por `opencode serve` compartilhado (2026-09-15):** o launch do terminal (`worktree next|plan|new|fix`) deixou de abrir um TUI local dono da sessão — `opencodeLaunchDirective` (`scripts/lib/worktree.mjs`) agora emite `launch node scripts/agent-session.mjs start --purpose=

--dir= --model= [--issue=] [--argument=""]` (o mapa purpose→skill mudou de casa para `scripts/lib/agent-session.mjs`, `purposeInvocation`; a mecânica do `.agents/shell/worktree.sh` ficou intocada — 1 linha `launch` tokenizada por xargs). O novo `scripts/agent-session.mjs` (`serve|start|attach|stop|list [--json]`, `pnpm agent:session`) sobe/reaproveita o `opencode serve` **compartilhado em loopback `127.0.0.1:4199`** (`TEQO_AGENT_SERVER_HOST/PORT`; bind fora do loopback exige `OPENCODE_SERVER_PASSWORD`, fail-closed; nunca `--mdns`), cria a sessão endereçável (`POST /session?directory=…`), dispara o **driver destacado** `opencode run --attach … --auto --command -- ` (é o `--auto` dele que auto-aprova permissões enquanto o run vive) e **anexa** o TUI `opencode attach -s `. Fechar o terminal/TUI desconecta — o run continua (verificado ao vivo na 1.18.31: SIGKILL no cliente e `tmux kill-session` não abortam; `POST /session/{id}/abort` interrompe e o driver sai); encerrar é ato explícito (`agent:session stop` = abort + fim do driver + `stoppedAt`). Sessão/endereçamento ficam em `~/.local/state/teqo/agent-sessions/.json` (`TEQO_AGENT_SESSION_DIR`/`XDG_STATE_HOME`; campos `version, code, issue, purpose, branch, dir, sessionID, url, model, driverPid, logPath, startedAt, stoppedAt`) e `list --json` entrega `{version, server, sessions:[…status]}` — o contrato que o painel OPS109 consome. `start` reusa o run existente quando o driver vive ou a sessão está `busy` (nunca dois runs no mesmo branch; `--new` força) e `worktree kill` encerra a sessão antes de destruir o worktree (estado/lock removidos — sem sessão órfã). Unit novos: `tests/unit/agentSession.unit.spec.ts` (paths/overrides, fail-closed de bind, argv do driver/attach com separador `--`, mapa purpose→comando, parse/serialize, payload do `list --json`; RED→verde) e `tests/unit/worktree.unit.spec.ts` atualizado (diretiva nova). Docs: seção "Sessões persistentes" em `docs/AGENT-OPS.md`, bullet em `AGENTS-infra.md`, `AGENTS.md` e `.opencode/commands/worktree.md`. Sem migration/UI/access/Consent/e2e (smoke ao vivo em dir de scratch: detach com run vivo, reattach com replay, `stop` interrompendo, `list` `working→idle→stopped`). diff --git a/docs/plans/ops110-attach-detach-runs-agent-impl.md b/docs/plans/ops110-attach-detach-runs-agent-impl.md new file mode 100644 index 00000000..045a1dba --- /dev/null +++ b/docs/plans/ops110-attach-detach-runs-agent-impl.md @@ -0,0 +1,174 @@ +# Impl: OPS110 — Acompanhar runs longos de agente por attach/detach, sem manter terminal aberto + +Status: rascunho +Atualizado em: 2026-09-15 +Issue: #1019 +Intenção: docs/plans/ops110-attach-detach-runs-agent.md +Appetite restante: ~1–2 dias eng (herdado) — cabe: 1 CLI + 1 lib pura + 2 specs unitários + docs/changelog; sem migration/UI/access/Consent. + +## Leitura da intenção + +- **Outcome (nas palavras do aceite):** o run continua trabalhando sem nenhum cliente olhando (nenhum terminal/SSH aberto no workstation); (re)entrar de outro dispositivo retoma exatamente a **mesma sessão, mesmo worktree, mesmo histórico**; sair do cliente nunca é encerrar (encerrar é ato explícito); nada novo escutando além do loopback sem credencial (qualquer superfície além de `127.0.0.1` exige auth, fail-closed sem `OPENCODE_SERVER_PASSWORD`); worktree/branch segue o contrato de ambiente (sem nova forma de claim, sem sessão órfã); a sessão é **endereçável** a partir da Issue/worktree — contrato que o OPS109 (painel) consumirá. +- **O que NÃO negociar:** não vira pool/supervisor (OPS65 removido — não ressuscitar); não cria caminho que burle gates humanos (PR/CI/approval); não cria segunda porta de entrada que ignore claim/worktree; não exige terminal aberto nem processo pendurado em SSH; não abre porta pública/internet; não é web UI como produto; sem notificações/frota/métricas; sem mudar claim/gates. Impeccable A — N/A (ferramenta/ops, sem UI). +- **O que reavaliar (hipóteses da intenção/explorador):** + - "Servidor nativo (a) vs multiplexador tmux (b)" → **decidido neste plano:** servidor + attach (D1/D2). O tmux cai porque não tem auto-approve fora do TUI, não dá session ID/URL endereçável para o OPS109 e adiciona dependência externa/preso a um pty. + - "o `opencode serve` sozinho sustenta o run" → **falso**: sessão só-servidor sem cliente fica **pendurada na permissão de bash** (fato verificado ao vivo; request fica em `/permission`). O design passa a exigir um **driver destacado vivo pelo run inteiro** — é o `--auto` dele que aprova, como o `--auto` do launch de hoje. + - "HTTP puro (`prompt_async`/REST) resolve sem cliente" → **falso** pelo mesmo fato; rejeitado em D2. + - "reusar o 4096 (Zed `opencode acp`) ou o 4097 (`opencode web` do usuário)" → **rejeitado**: processos de terceiros na máquina; servidor próprio em **4199** (`TEQO_AGENT_SERVER_PORT` overridável), fora da faixa de dev dos worktrees (`3100+slot`, slot ≤ 999 → até 4099). + - "a sessão só existe quando o TUI abre" → **falso**: `POST /session?directory=D` cria a sessão antes do run; o ID já nasce endereçável e vai para o estado no `start`. + - "`opencode attach`/TUI fecha o run" → **falso** (probes): SIGKILL no cliente `run`, fechar o TUI e `tmux kill-session` **não** abortaram o run server-side; `POST /session/{id}/abort` interrompe e o driver sai. Ctrl+C no TUI pode limpar input em vez de sair (não aborta o run). + +## Abordagem recomendada + +```mermaid +flowchart TD + W["worktree next/plan/new/fix (terminal)"] --> L["launch node scripts/agent-session.mjs start
--purpose P --dir D --model M [--issue N] [--argument B]"] + L --> H{"GET 127.0.0.1:4199/global/health"} + H -->|"down / estranho"| SV["spawn detached: opencode serve
--hostname 127.0.0.1 --port 4199 + log"] + H -->|"up"| ST + SV --> ST{"estado do branch existe
e sessão viva?"} + ST -->|"sim"| AT["opencode attach URL -s SES (foreground)"] + ST -->|"não"| CS["POST /session?directory=D
→ sessionID endereçável"] + CS --> DR["spawn detached: opencode run --attach URL -s SES
--model M --auto --command CMD -- args + log"] + DR --> AT + AT -.->|"fechar terminal (SIGHUP) = detach"| RUN["run segue no servidor
(o --auto do driver aprova)"] + RUN -.->|"laptop/celular"| AT2["agent-session attach
(--session | --branch | --issue | branch do cwd)"] + AT2 -.-> RUN + AT2 -->|"ato explícito"| STOP["agent-session stop
POST /session/id/abort + kill do driver + stoppedAt"] + STOP --> LJ["list --json (contrato OPS109)"] + RUN --> LJ +``` + +**Opções consideradas:** A) `opencode serve` compartilhado em loopback (127.0.0.1:4199) + driver destacado por run + `opencode attach` como cliente TUI; B) multiplexador de terminal (tmux) mantendo o TUI vivo entre anexações; C) servidor + HTTP puro (`prompt_async`/REST) sem cliente/driver vivo; D) um `opencode serve` por worktree. +**Recomendação:** **A** — é a única direção que preserva os três aceites ao mesmo tempo: o run sobrevive sem cliente (servidor dono da sessão + driver `--auto` destacado), o reattach é exato (`-s ` no mesmo `--dir`) e a sessão é endereçável (`sessionID` + `url` no estado, base do contrato OPS109). Os probes ao vivo na 1.18.31 validaram cada peça: fechar/SIGKILL no cliente não aborta; o exit do driver é 0 ao concluir; `abort` interrompe e o driver sai; `--command` com separador `--` entrega `$ARGUMENTS` corretamente (`work-issue -- --issue 1019` passou `"--issue 1019"`). +**Alternativas rejeitadas:** B — sem auto-approve fora do TUI (o run ficaria pendurado na permissão assim que o humano saísse), sem session ID/URL estável para o OPS109 e dependência externa (tmux) no caminho crítico; C — a permissão de bash pendura sem cliente vivo (fato verificado) e não há como "responder depois" sem superfície de permissão; D — N portas/processos para gerenciar, URLs múltiplas para o OPS109 e o mesmo custo de driver por run (nenhum ganho de isolamento, já que as sessões são escopadas por `directory`). + +### Decisões de engenharia (caras de reverter) + +#### D1 — Topologia do servidor + +- **Opções:** A) um `opencode serve` compartilhado em `127.0.0.1:4199` (`TEQO_AGENT_SERVER_PORT`), sessões escopadas por `directory` (recomendada); B) um servidor por worktree; C) reusar 4096/4097 de terceiros; D) porta efêmera com registro em arquivo. +- **Recomendação:** **A** — um único processo loopback, uma URL estável por workstation, endereçamento simples para o OPS109; `serve` é idempotente (health check antes de subir). +- **Alternativas rejeitadas:** B — N processos/portas e N URLs, sem ganho (sessões já isolam por diretório); C — são donos de terceiros (Zed ACP, `opencode web` do usuário), não nossos para gerenciar/derrubar; D — endereço instável entre reboots quebra o contrato de endereçamento do OPS109. + +#### D2 — Quem sustenta o run (driver destacado) + +- **Opções:** A) driver destacado por run (`opencode run --attach URL -s SES --dir DIR --model M --auto --command CMD -- `, spawn `detached: true` + `unref()` + log em fd, precedente `scripts/auto-unblock.mjs:344–374`) (recomendada); B) `prompt_async`/HTTP puro sem cliente; C) tmux com o TUI vivo; D) confiar no servidor sozinho. +- **Recomendação:** **A** — o driver é quem carrega o `--auto` do run inteiro e auto-aprova permissões; verificado que ele sai com exit 0 ao concluir, que SIGKILL no cliente não aborta e que `POST /session/{id}/abort` interrompe o run e faz o driver sair. +- **Alternativas rejeitadas:** B — permissão pendura sem cliente (verificado); C — sem `--auto` fora do TUI e sem session ID/URL estável; D — o servidor não aprova permissões por conta própria. + +#### D3 — UX do `start` (TUI anexado em foreground) + +- **Opções:** A) `start` termina com `opencode attach -s --dir

` no foreground, stdio herdado (recomendada); B) `start` retorna sem abrir nada (só imprime URL/ID); C) abrir um TUI local antigo. +- **Recomendação:** **A** — mantém a UX atual do launch (o chat abre no worktree, agora como cliente do servidor) e o detach explícito é fechar o terminal; sair do TUI não encerra o run. +- **Alternativas rejeitadas:** B — muda a UX do `worktree next/plan/...` e adiciona um passo manual; C — TUI local não fala com o servidor e conflita com o endereçamento. + +#### D4 — Estado por run e contrato de endereçamento (OPS109) + +- **Opções:** A) JSON por run em `~/.local/state/teqo/agent-sessions/.json` (`TEQO_AGENT_SESSION_DIR` overridável; `XDG_STATE_HOME` respeitado), campos estáveis `version, code, issue, purpose, branch, dir, sessionID, url, model, driverPid, logPath, startedAt, stoppedAt` (recomendada); B) sem arquivo, redescobrir tudo pela API do servidor; C) banco/daemon próprio de sessões. +- **Recomendação:** **A** — o opencode persiste a sessão; o arquivo é só o **índice de endereçamento** (branch/dir/sessionID/url/driverPid/log). Sem segredo persistido: credencial só por env (`OPENCODE_SERVER_PASSWORD`/`OPENCODE_SERVER_USERNAME`), nunca no estado/log. `code` derivado do prefixo do branch (`C15-…`), `null` nos namespaces `plans/`, `work/`, `fix/`. +- **Alternativas rejeitadas:** B — a API do servidor não guarda issue/branch/purpose/driverPid; redescobrir exigiria heurística e quebraria o OPS109; C — rabbit hole de produto ("segundo dono da sessão"). + +#### D5 — Reuso de sessão, encerramento e `worktree kill` + +- **Opções:** A) `start` com estado existente e sessão viva → apenas attach (não duplica driver/sessão); `--new` força sessão nova; servidor/sessão inexistente (reboot) → nova sessão; `stop` = abort via API + kill do driver + `stoppedAt`; `worktree kill` chama stop antes de remover e apaga o estado ao fim (recomendada); B) sempre criar sessão nova; C) nunca reusar/nunca parar. +- **Recomendação:** **A** — cobre `worktree next --issue N` idempotente (worktree reaberto não pode ganhar dois runs no mesmo branch) e cumpre "sem sessão órfã". Estado vivo mas com script `start` concorrente é protegido por lock simples (ver R4). +- **Alternativas rejeitadas:** B — dois runs no mesmo worktree/branch = corrupção de branch/PR; C — órfãos. + +#### D6 — Dono do mapa purpose→comando/args e nova diretiva de launch + +- **Opções:** A) mover `OPENCODE_SKILL_COMMAND_BY_PURPOSE` (hoje em `scripts/lib/worktree.mjs:91–96`) e a montagem command/args para `scripts/lib/agent-session.mjs`; `opencodeLaunchDirective` (`scripts/lib/worktree.mjs:207–233`) passa a emitir `launch node scripts/agent-session.mjs start --purpose

--dir --model [--issue N] [--argument ""]` (recomendada); B) manter o mapa em `worktree.mjs` e o CLI de sessão chamá-lo de volta; C) duplicar o mapa no script novo. +- **Recomendação:** **A** — o launch segue sendo a **única porta de entrada**; `.agents/shell/worktree.sh` não muda de mecânica (continua 1 linha `launch` tokenizada por xargs); a sanitização do bag (remover `"`/`\`) e a regra "issue só no `next`" são preservadas no novo dono. +- **Alternativas rejeitadas:** B — inverte dependência e acopla o ciclo de vida ao provisionador; C — drift garantido entre dois mapas. + +#### D7 — Superfície de comando + +- **Opções:** A) um script com verbos `serve|start|attach|stop|list [--json]`, exposto como `"agent:session": "node scripts/agent-session.mjs"` no `package.json` (recomendada; precedente `pnpm issue`/`pnpm worktree`); B) um script por verbo; C) pendurar os verbos no `scripts/worktree.mjs`. +- **Recomendação:** **A** — cluster coeso do ciclo de vida; `scripts/*.mjs` já é entrada do knip (`knip.json` `entry`), então o script novo entra no grafo sem config nova. `attach`/`stop` resolvem por `--session` > `--branch` > `--issue` > branch do cwd (`git rev-parse --abbrev-ref HEAD`); `list --json` é o contrato de endereçamento do OPS109. +- **Alternativas rejeitadas:** B — 5 entrypoints, help/parse duplicados; C — mistura claim/provisionamento (worktree) com ciclo de vida de run (sessão). + +#### D8 — Verificação sem servidor real no CI + +- **Opções:** A) lib pura com `fetch`/`spawn`/`fs` injetáveis + unit tests; smoke ao vivo discricionário (recomendada); B) integração/e2e subindo `opencode serve` no CI; C) só smoke manual. +- **Recomendação:** **A** — o repo roda unit sem banco/rede de propósito; o que regride é o **contrato** (argv, paths de estado, validação fail-closed, mapa purpose→comando, shape do `list --json`). Atualizar `tests/unit/worktree.unit.spec.ts` (diretiva nova; o spec atual importa `OPENCODE_SKILL_COMMAND_BY_PURPOSE` da lib antiga — o import passa a vir do novo dono) e criar `tests/unit/agentSession.unit.spec.ts` sob `tests/{unit,int,e2e}` (guard `check-test-locations`). +- **Alternativas rejeitadas:** B — daemon externo/binary local no runner, flake e fora do padrão (o opencode já é smoke, não teste); C — o contrato crítico (endereçamento OPS109, fail-closed de bind) ficaria sem pin. + +#### D9 — Segurança do bind e da credencial + +- **Opções:** A) default loopback sem auth (aceito pelo produto); qualquer `--hostname` não-loopback **exige** `OPENCODE_SERVER_PASSWORD` ou falha fechado antes do spawn; **nunca** `--mdns`; credencial só por env (recomendada); B) espelhar o opencode (apenas avisa sem senha); C) bind na tailnet sem auth "porque a rede é confiável". +- **Recomendação:** **A** — `validateServerBind({ hostname, password })` na lib pura, testado para loopback (`127.0.0.1`, `::1`, `localhost`) e para o caminho que lança sem senha; a credencial nunca vai para o estado/log. Repasse ao filho por env (hipótese: o cliente lê `OPENCODE_SERVER_PASSWORD`; se não ler, fallback `-p` documentado — a validar no tracer). +- **Alternativas rejeitadas:** B — viola o aceite "fail-closed sem credencial"; C — anti-goal explícito da intenção. + +#### D10 — Daemon do servidor na v1 + +- **Opções:** A) sem parada automática: `serve` idempotente + PID gravado no estado global (`server.json`) + parada manual documentada (recomendada); B) `serve --stop` completo com restart supervisionado; C) matar por `pkill opencode`. +- **Recomendação:** **A** — loopback e idle custam pouco; manter o daemon evita matar o servidor no meio de um run. Registrar em `AGENT-OPS.md`; `serve --stop` fica como gatilho barato se o smoke mostrar necessidade. +- **Alternativas rejeitadas:** B — supervisão de daemon é outro item (fora do appetite); C — padrão perigoso: mataria os servidores de terceiros (4096/4097). + +### Componentes / mudanças + +- **`scripts/lib/agent-session.mjs` (novo, lib pura, testável):** dono dos símbolos — `AGENT_SERVER_PORT` (4199, `TEQO_AGENT_SERVER_PORT`), `AGENT_SERVER_HOST` (127.0.0.1), `AGENT_SESSION_DIR` (`TEQO_AGENT_SESSION_DIR` ?? `$XDG_STATE_HOME/teqo/agent-sessions` ?? `~/.local/state/teqo/agent-sessions`), `STATE_VERSION`, `isLoopbackHost`, `validateServerBind`, `sessionSlug` (reusa `slugify` de `src/lib/slug.ts`, já importado pela lib de worktree), `sessionStatePath`, `serializeSessionState`/`parseSessionState`, `purposeInvocation({ purpose, issueNumber, argument })` (mapa movido: `next → { command: 'work-issue', arguments: '--issue N' }`, `plan → { command: 'plan-issue', arguments: '' }`, `fix → { command: 'bug-fix', arguments: bag }`, `new → null`; sanitização do bag idempotente também aqui), `driverArgs`/`attachArgs`/`serverArgs` (SEM o binário — o caller usa `spawn('opencode', …)`; nenhum carrega `-p`, a credencial só vive no env), `resolveSessionRef({ states, session, branch, issue, cwdBranch })`, `formatSessionList(states, probes)`/`sessionListPayload`. +- **`scripts/agent-session.mjs` (novo, CLI fina):** verbos `serve` (health check → spawn `opencode serve --hostname --port

` detached + log; PID em `server.json`), `start` (reuso D5 → cria sessão `POST /session?directory=D` → spawn do driver detached com log → attach foreground), `attach` (`opencode attach -s [--dir

]`), `stop` (resolve ref → `POST /session/{id}/abort` → SIGTERM/SIGKILL no `driverPid` → grava `stoppedAt`), `list [--json]` (varre o state dir; probe `GET /global/health` + `GET /session/status?directory=D`; status `working|idle|stopped|unknown`). Aviso de drift: comparar `version` do `/global/health` com `opencode --version` se não for igual. `worktree kill` delega o encerramento ao próprio CLI (`node scripts/agent-session.mjs stop --branch=`, best-effort; estado só é descartado quando o stop funciona) — sem segunda implementação de abort/kill. +- **`scripts/lib/worktree.mjs`:** `opencodeLaunchDirective` emite a linha nova (uma linha `launch node scripts/agent-session.mjs start ...`, dir sem espaços); remover `OPENCODE_SKILL_COMMAND_BY_PURPOSE` (dono passa a ser a lib nova); comentário/doc da função reescrito. `resolveWorktreeModel`/`WORKTREE_MODEL_MAP`/diretiva headless OPS106 intocados. +- **`scripts/worktree.mjs`:** caminho do `kill` chama `stopSession` (lib nova, best-effort: servidor inacessível → segue o kill do PID/estado) antes do `git worktree remove`; ao fim da remoção, apaga o estado do branch. Help text e comentários realinhados. `next`/`plan`/`new`/`fix` inalterados fora da diretiva. +- **`.agents/shell/worktree.sh`:** só comentários (l.9–26 e l.97–111) — mecânica intocada (1 linha `launch`, xargs, `cd` primeiro). Hipótese a validar no smoke: `launch` preserva o cwd (o caminho `scripts/agent-session.mjs` é relativo); se não preservar, o fallback é o `worktree.mjs` emitir o caminho absoluto (`node /scripts/agent-session.mjs`) — 1 linha. +- **`package.json`:** `"agent:session": "node scripts/agent-session.mjs"`. +- **`tests/unit/worktree.unit.spec.ts`:** expectativas da diretiva nova; import do mapa vem da lib nova (ou o pin do mapa muda de casa). Demais pins (modelo, branches, env) intactos. +- **`tests/unit/agentSession.unit.spec.ts` (novo):** paths/override do state dir, `sessionSlug` de branch com `/`, `validateServerBind` fail-closed (não-loopback sem senha lança; loopback nunca lança; `--mdns` nunca emitido), `driverArgv` com `--attach`/`-s`/`--auto`/`--command` + separador `--` e args (`--issue 1019` só no `next`; bag só no `fix`, sanitizado), `attachArgv`, `purposeInvocation`, `parseSessionState`/roundtrip, `formatSessionList`/payload do `list --json`. +- **Docs:** seção nova em `docs/AGENT-OPS.md` (ciclo de vida, comandos, detach, stop, estado, contrato OPS109, loopback/credencial, modos de falha: morte do driver → permissão pendura; reboot → histórico persiste, run não; Ctrl+C limpa input, sair = fechar terminal/keybind `app_exit`); bullet do worktree em `AGENTS-infra.md`; changelog `docs/changelog/2026-09-15-ops110.md` (não editar `docs/CHANGELOG-AGENTS.md` nem o `HISTORY`). +- **Sem migration, sem UI, sem access/Consent/PII, sem e2e** (diff é scripts + specs + docs; smoke ao vivo discricionário). + +### Dados → forma (N/A — sem dados) + +Ferramenta/ops: os "dados" são o estado operacional do run (`sessionID`, status) exposto em `list --json` para o OPS109 — texto/JSON de máquina, sem métrica, gráfico ou dado eleitoral/contato. Nada a desenhar. + +## Fases verificáveis + +1. **Tracer — lib pura + contrato da diretiva (RED → verde).** Escrever primeiro `tests/unit/agentSession.unit.spec.ts` (caminhos, fail-closed de bind, argv, mapa purpose→comando, estado, list) — **RED** contra o repo atual (módulo não existe; diretiva ainda não é a nova). Extrair `scripts/lib/agent-session.mjs`, mover o mapa e ajustar `opencodeLaunchDirective`; atualizar `tests/unit/worktree.unit.spec.ts`. Gate: specs focados verdes + `pnpm gate:fast`. _~3–4h._ +2. **CLI `agent-session.mjs` — serve/start/attach/stop/list.** Fiar health check, spawn destacado do servidor e do driver (log em fd, `unref`), criação de sessão via API, attach foreground, stop/abort, `list --json`. Verificação: unit tests da lib via funções puras com fetch/spawn injetáveis (sem rede/no CI) + `node scripts/agent-session.mjs list --json` com state dir vazio/inexistente retornando payload válido (fail-soft, sem stacktrace). Gate: `pnpm gate:fast` + `pnpm knip`. _~5–6h._ +3. **Integração no launcher + kill.** `package.json`, hook do `kill` no `scripts/worktree.mjs`, comentários da shell. Verificação: specs de diretiva verdes; `TEQO_WORKTREE_TERMINAL=1 node scripts/worktree.mjs plan --stay` não emite launch (regra atual preservada) e o spec pina a linha emitida; `worktree kill` com estado de sessão chama stop antes do remove (coberto no smoke). Gate: `pnpm gate:fast`. _~2–3h._ +4. **Docs + changelog.** Checklist da seção Componentes (`AGENT-OPS.md`, `AGENTS-infra.md`, `.agents/shell/worktree.sh`, `docs/changelog/2026-09-15-ops110.md`; `pnpm format` se o Prettier reclamar). Gate: `pnpm push` (o `gate:ci` cobre docs-guards). _~1–2h._ +5. **Smoke ao vivo (discricionário) + entrega.** Em dir/worktree de **scratch** (nunca um claim real): `agent-session start` com comando de scratch, fechar o cliente/SIGKILL no TUI, confirmar run vivo via `list`/`/session/status`, reattach, `stop` interrompe o run e o driver sai; `worktree kill` sem sessão órfã (PID/driver mortos, estado removido). Registrar o resultado no changelog; `pnpm push`; PR Ready. _~2h + observação pós-merge._ **Total ~13–17h (~2 dias eng).** + +## Rabbit holes / Não escopo (engenharia) + +- **Painel de frota / web UI / pool** — status de todos os runs, iniciar/parar por lista, notificações, métricas, supervisor (anti-goals; OPS109 consome só o `list --json`). +- **Expor bind na tailnet sem auth** ou "porque a rede é confiável" — qualquer superfície além do loopback exige credencial, fail-closed; `--mdns` nunca. +- **Persistência própria de sessão** (banco/daemon guardando estado do run) — o opencode já persiste; aqui só o índice de endereçamento. +- **Lock manager/scheduler completo** — a corrida de `start` resolve com lockfile simples por slug (R4), não vira fila de runs. +- **Gerenciar/derrubar os servidores de terceiros (4096/4097)** ou "reusar um deles". +- **Rotação de log, restart supervisionado do daemon, telemetria** — v1 aceita o log append + daemon idle (R2/R10). +- **Integração/e2e com servidor real no CI** e **migration/UI/access/Consent** — sem superfície; smoke discricionário. +- **Mudar claim/worktree/gates**, criar segunda porta de entrada ou sessão sem branch/claim. + +## Riscos e mitigação + +- **R1 — Morte do driver → permissão pendurada** (o run fica esperando um cliente que não existe). _Mitigação:_ o attach pode responder a permissão; `stop` aborta via API em modo tolerante (401/5xx não impedem o encerramento local do driver + `stoppedAt`); documentar em `AGENT-OPS.md` (sintoma + remédio). +- **R2 — Daemon do servidor sem parada na v1.** Aceito (loopback, idle); PID no `server.json` para parada manual; `serve --stop` deferido como gatilho barato (D10). +- **R3 — Drift de flags/API do opencode** (servidor/attach/run). _Mitigação:_ `/global/health` traz `version` e o `start` avisa se ≠ `opencode --version`; unit tests pinam o argv; smoke ao vivo; os fatos deste plano são da 1.18.31. +- **R4 — Dois `start` simultâneos no mesmo branch/servidor** (driver/sessão duplicados). _Mitigação:_ lockfile por slug (`fs.openSync(..., 'wx')` com PID e reclaim se o PID morreu; sem dependência nova) com a **decisão de reuso relida dentro do lock**; segundo `start` sai com mensagem clara; caminho coberto pela lib (a corrida real não tem harness no repo — lacuna registrada no PR). +- **R5 — Ctrl+C no TUI anexado limpa input em vez de sair** (probe; não aborta o run). _Mitigação:_ documentar as chaves de saída (`app_exit: ctrl+c, ctrl+d, q`; fechar terminal/SIGHUP = detach) e que encerrar é `agent-session stop`. +- **R6 — Bind não-loopback sem senha** (violaria o aceite). _Mitigação:_ `validateServerBind` falha antes do health/reuse (não só antes do spawn); `isLoopbackHost` estrito (não aceita `127.0.0.1.evil`); unit tests dedicados; `--mdns` nunca emitido. +- **R7 — Hypotese do repasse de credencial por env** (`opencode attach`/`run` lerem `OPENCODE_SERVER_PASSWORD` sem `-p`). _Mitigação:_ fallback `-p` documentado (trade-off: visível em `ps` local) e validado no tracer; a credencial nunca entra no estado/log. +- **R8 — Worktrees reabertos/criados antes do merge não têm `scripts/agent-session.mjs`.** _Resolvido em execução:_ a diretiva carrega o caminho **absoluto** do checkout que a emitiu (`opencodeLaunchDirective` ganhou `sessionScript`; o default relativo fica para os specs) — o relaunch de worktree antigo não quebra nem fica pinado no código do branch. +- **R9 — Contrato `list --json` (OPS109) evoluir quebrando o painel.** _Mitigação:_ `version` no estado e no payload; evolução additions-only; contrato documentado em `AGENT-OPS.md`. +- **R10 — Log do driver sem rotação.** Aceito na v1 (arquivo append no state dir, caminho no estado); se crescer, truncar é trivial e não muda o contrato. +- **R11 — `new` sem run dirigido** (sem driver enquanto o humano não conversar; permissões interativas dependem do cliente anexado). _Mitigação:_ `start --purpose new` cria sessão + attach sem driver (hipótese: `run` sem mensagem não é confiável — validar no tracer); documentar a limitação; o aceite de detach vale para runs dirigidos (`next`/`plan`/`fix`). + +## Aceite de engenharia + +- [ ] Aceite de produto da intenção coberto: (1) run continua sem nenhum cliente olhando; (2) reattach exato de outro dispositivo (mesma sessão `-s`, mesmo `--dir`, mesmo histórico); (3) sair do cliente ≠ encerrar (stop é ato explícito); (4) nada além do loopback sem credencial — `--hostname` não-loopback sem `OPENCODE_SERVER_PASSWORD` falha fechado; (5) worktree/branch segue o contrato, sem nova forma de claim nem sessão órfã (`worktree kill` para a sessão antes de remover); (6) sessão endereçável por Issue/branch/worktree via estado + `list --json` (contrato OPS109). +- [ ] Invariantes AGENTS/engineering-standards: sem migration/UI/access/Consent/PII; claim/gates intocados; a diretiva de launch continua a única porta (`opencodeLaunchDirective`); shell function sem mudança de mecânica; sem segredo persistido/commitado; `scripts/*.mjs` como entrada do knip e specs em `tests/unit/` (guard `check-test-locations`); sem dependência nova. +- [ ] Testes previstos: `tests/unit/agentSession.unit.spec.ts` novo (paths/overrides, fail-closed de bind, argv do driver/attach com separador `--`, mapa purpose→comando+args, parse/serialize, `list --json`) com RED comprovado contra o repo atual; `tests/unit/worktree.unit.spec.ts` atualizado (diretiva nova) e demais pins verdes. +- [ ] Gates: `pnpm gate:fast` na iteração e `pnpm test:unit` (full); `pnpm push` (o `gate:ci` cobre format/lint/typecheck/unit/int/knip/cycles/docs-guards); e2e: **declarar "sem superfície de runtime"** (diff é scripts + specs + docs; OPS72 discricionário — o CI do PR aplica o conjunto curado por high-risk, pois `package.json`/`scripts/worktree.mjs` estão no blast radius). Smoke ao vivo com dir/worktree de **scratch** (sem claim real): detach com run vivo, reattach, `stop`, `worktree kill` — resultado registrado no changelog; incluir o `*-impl.md` no commit; PR Ready. + +**Self-score decision-quality (gate ≥4):** + +| Critério | Nota | Justificativa | +| -------------------------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1. Decisões caras com rejeitadas | 1,0 | D1–D10 com opções e rejeitadas explícitas (topologia, driver vs tmux vs HTTP, estado, reuso/kill, dono do mapa, superfície, verificação, bind, daemon) | +| 2. Cabe no appetite | 1,0 | ~13–17h: 1 CLI + 1 lib pura + 2 specs unitários + hook de kill + docs; sem migration/UI/e2e | +| 3. Rabbit holes nomeados | 1,0 | frota/web UI/pool, bind sem auth/`--mdns`, persistência própria, lock manager, servidores de terceiros, rotação/supervisão, e2e com servidor real, mudar claim/gates | +| 4. Depth check (reuso) | 1,0 | reusa `slugify`, o spawn destacado do `auto-unblock.mjs:344–374`, a mecânica da shell (1 linha `launch`/xargs), o padrão de cluster `pnpm issue`/`worktree`, estado em XDG; edita os donos (diretiva/lib/worktree) sem twin | +| 5. Intenção preservada | 1,0 | os 6 aceites cobertos; direção (a) escolhida; anti-goals respeitados; endereçamento OPS109 explícito no `list --json`; produto decide "de onde acompanha" (SSH/TUI, já validado) | +| **Total** | **5,0/5** | ≥4 exigido | diff --git a/docs/plans/ops110-f1-agent-session-hardening.md b/docs/plans/ops110-f1-agent-session-hardening.md new file mode 100644 index 00000000..4e8d3fbd --- /dev/null +++ b/docs/plans/ops110-f1-agent-session-hardening.md @@ -0,0 +1,59 @@ +# OPS110-F1 — `agent:session`: flags desconhecidas fail-high, single-flight do `start` testado e polish de help/list + +Status: rascunho +Atualizado em: 2026-09-15 +Issue: (a registrar — OPS110-F1) +Intenção: entrega OPS110 (Issue #1019, `docs/plans/ops110-attach-detach-runs-agent.md`) — débitos do ciclo `/simplify` capturados após o merge +Appetite restante: ~1 dia eng (3 fases, fill-in) + +## Leitura da intenção + +- **Outcome:** fechar as lacunas deixadas pelo ciclo OPS110 sem mudar o contrato de produto: (a) flags desconhecidas no CLI `agent:session` passam a falhar alto (hoje `--prupose=next` cai silenciosamente no default `new`, criando sessão sem driver); (b) o invariante "nunca dois runs no mesmo branch" (D5/R4 do impl plan) ganha pin automatizado do single-flight (`acquireStartLock` + reuso dentro do lock); (c) help/`list` ficam completos (flags `--hostname/--port` e obrigatoriedade do `--model` no USAGE do `start`; `logPath` visível no `list` humano). +- **O que NÃO negociar:** o contrato de `list --json` (OPS109), o fail-closed de bind, a credencial só por env e o comportamento de attach/detach (fechar cliente não encerra; `stop` é explícito) ficam como estão. +- **O que reavaliar:** nenhum achado de access/LGPD/schema. + +## Abordagem recomendada + +**Opções:** A) 3 fases curtas no mesmo lote (flags → teste do lock → help/list) | B) só a fase de flags agora e o resto defer | C) unificar tudo num refactor do CLI. +**Recomendação:** A — as três são cheap_polish na mesma superfície (`scripts/agent-session.mjs`) e o lote cabe em ~1 dia; a fase 1 é a de maior risco (falha muda no caminho crítico), a 2 pin o invariante mais caro de reverter (dois runs no mesmo worktree), a 3 é cosmético. +**Rejeitadas:** B — deixa sem pin o único invariante de segurança de processo do item; C — sem ganho, o CLI já foi extraído na OPS110. + +### Fases verificáveis + +1. **Flags estritas (F1).** `scripts/agent-session.mjs`: validador de flags aceitas por verbo (whitelist: `serve` hostname/port; `start` purpose/dir/model/issue/argument/new/hostname/port; `attach` session/branch/issue; `stop` idem; `list` json) — desconhecida → `die` com sugestão da mais próxima (distância 1) ou listando o USAGE. Unit novo do validador (puro, em `scripts/lib/agent-session.mjs`) + `pnpm gate:fast`. ~2–3h. +2. **Single-flight com seam testável (F2).** Extrair de `cmdStart` a decisão "reusar vs criar" para uma função com I/O injetável (ex. `resolveStartDecision({ statePath, readFile, serverUrl, probeBusy, driverAlive, forceNew })`) na lib pura; unit cobre: estado vivo → reuse; estado stale (driver morto e idle) → fresh; `--new` → fresh; e o reclaim do lock com PID morto (hoje só `startLockPath` é pinado). Documentar no spec a lacuna que fica (corrida real de processos). ~3–4h. +3. **Help/list (F3).** USAGE do `start` com `--hostname/--port` e "`--model` obrigatório quando há skill"; `list` humano ganha a coluna do `logPath` (mantendo o header). Spec de `formatSessionList` atualizado. ~1h. + +### Dados → forma (N/A — sem dados) + +## Rabbit holes / Não escopo (engenharia) + +- Refactor do CLI em módulos por verbo (a CLI fina atual é intencional). +- Persistir credencial para `list`/`attach` sem env (proibido pelo D9 da OPS110). +- Validação semântica de todos os valores de flag (ex. e-mail de branch) — fora. + +## Adiado com gatilho (triage OPS110) + +- **Fronteira dupla da regra "issue só no `next`" e da sanitização do bag** (`scripts/lib/worktree.mjs` na diretiva ∥ `scripts/lib/agent-session.mjs` no `purposeInvocation`): unificar no dono do ciclo de vida **quando** `plan`/`new` ganharem argumento próprio ou surgir um 3º purpose com bag/issue. Hoje há comentário cruzado nos dois pontos e pins nos dois specs. + +## Explicitamente fora (skips e descartes da triage — não reabrir) + +- Literal `/bug-fix` em dois donos (OPS106 × OPS110): os dois specs pinam juntos num rename; unificar acoplaria o auto-unblock ao ciclo de sessão. +- 404 no `abort` tratado como "já encerrado": semanticamente correto (sessão inexistente não está rodando). +- `stop`/`list`/`attach` dependerem de `OPENCODE_SERVER_PASSWORD` no env quando o servidor exigiu senha: decisão D9 (credencial nunca persistida); `stop` degrada para encerramento local e o hint é acionável. +- Marcador `(line truncated to 2000 chars)` commitado em `AGENTS-infra.md:15`: pré-existente em `main`, fora do diff da OPS110. +- knip não carregar `src/payload.config.ts`: P3 já ledgered (`docs/TECH-DEBT.md`). + +## Riscos e mitigação + +- Whitelist de flags divergir do parser `parseEqualsFlags` (que aceita `--flag` sem valor): F1 deve rejeitar flag desconhecida, não mudar a forma `=`; teste cobre `--json`/`--new` booleanos. +- F2 extrair decisão sem mover I/O para a lib: manter a lib pura (filesystem/probe entram por parâmetro). + +## Aceite de engenharia + +- [ ] Flag desconhecida em qualquer verbo falha com mensagem acionável (inclui `--prupose`→`--purpose`). +- [ ] Decisão de reuso + reclaim do lock pinados em unit puro; corrida real documentada como lacuna. +- [ ] USAGE do `start` completo; `list` mostra o log. +- [ ] `pnpm gate:fast` verde; sem migration/UI/access. + +**Self-score decision-quality (gate ≥4):** decisões A/B/C com rejeitadas (1,0); cabe no appetite ~1 dia (1,0); rabbit holes nomeados (1,0); reusa a lib/CLI existentes sem twin (1,0); contrato OPS110 preservado (1,0) → **5,0/5**. diff --git a/knip.json b/knip.json index f3e67b35..161e29c8 100644 --- a/knip.json +++ b/knip.json @@ -18,7 +18,7 @@ "paths": { "server-only": ["tests/helpers/serverOnly.ts"] }, - "ignoreBinaries": [], + "ignoreBinaries": ["opencode"], "ignoreDependencies": [ "tailwindcss-animate", "tw-animate-css", diff --git a/package.json b/package.json index b45b75ef..a64946d5 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,7 @@ "typecheck:tsc6": "tsc6 --noEmit", "prepare": "husky", "agent:status": "node scripts/agent-status.mjs", + "agent:session": "node scripts/agent-session.mjs", "worktree": "node scripts/worktree.mjs", "unblock:check": "node scripts/auto-unblock.mjs --check", "issue": "node scripts/issue.mjs" diff --git a/scripts/agent-session.mjs b/scripts/agent-session.mjs new file mode 100644 index 00000000..80fc000c --- /dev/null +++ b/scripts/agent-session.mjs @@ -0,0 +1,678 @@ +#!/usr/bin/env node +/** + * OPS110 — `pnpm agent:session` (CLI do ciclo de vida de runs persistentes). + * + * pnpm agent:session serve [--hostname=H] [--port=P] + * Sobe (ou reaproveita) o `opencode serve` COMPARTILHADO em loopback — + * ele é o dono das sessões e sobrevive a qualquer cliente. Bind fora do + * loopback exige `OPENCODE_SERVER_PASSWORD` (fail-closed). Nunca `--mdns`. + * pnpm agent:session start --purpose= --dir= --model= [--issue=N] [--argument=""] [--new] + * Cria a sessão endereçável, sobe o driver destacado + * (`opencode run --attach … --auto --command -- …`) quando o + * purpose tem skill, e abre o TUI anexado (`opencode attach -s `). + * Fechar o TUI/terminal NÃO encerra o run; `stop` encerra. Reusa o run + * existente quando o driver ainda vive ou a sessão está busy; `--new` + * força sessão nova. + * pnpm agent:session attach [--session=|--branch=|--issue=N] + * (Re)entra na sessão — default: o branch do cwd. Sobe o servidor se + * ele estiver fora (o histórico da sessão persiste). + * pnpm agent:session stop [--session=|--branch=|--issue=N] + * Ato explícito de encerrar: abort via API + fim do driver + `stoppedAt`. + * pnpm agent:session list [--json] + * Estado dos runs (orquestrado pelo `serve`); `--json` é o contrato + * versionado que o painel OPS109 consome. + * + * Estado por run em `~/.local/state/teqo/agent-sessions/.json` (índice de + * endereçamento: branch/dir/sessionID/url/driverPid/log); nenhuma credencial é + * persistida. Os fatos de semântica (cliente pode morrer, `abort` interrompe, + * `--` entrega `$ARGUMENTS`) foram verificados na 1.18.31. + */ +import { spawn, spawnSync } from 'node:child_process' +import { + closeSync, + existsSync, + mkdirSync, + openSync, + readdirSync, + readFileSync, + unlinkSync, + writeFileSync, +} from 'node:fs' +import { dirname, join, resolve } from 'node:path' + +import { + attachArgs, + codeFromBranch, + deriveSessionStatus, + driverArgs, + driverLogPath, + formatSessionList, + parseServerState, + parseSessionState, + purposeInvocation, + resolveServerConfig, + resolveSessionRef, + serializeServerState, + serializeSessionState, + serverArgs, + serverStatePath, + sessionDirFromEnv, + sessionListPayload, + sessionStatePath, + startLockPath, + STATE_VERSION, + validateServerBind, +} from './lib/agent-session.mjs' +import { dieWithLabel, parseEqualsFlags } from './lib/cli.mjs' + +const die = dieWithLabel('agent:session') + +const USAGE = `Uso: pnpm agent:session [flags] + + serve [--hostname=H] [--port=P] sobe/reaproveita o opencode serve compartilhado + start --purpose= --dir= --model= [--issue=N] [--argument=""] [--new] + cria a sessão, sobe o driver destacado e anexa o TUI + attach [--session=|--branch=|--issue=N] (re)entra na sessão (default: branch do cwd) + stop [--session=|--branch=|--issue=N] encerra de forma explícita (abort + fim do driver) + list [--json] status dos runs registrados` + +/** Loopback by default; the credential rides the env, never the state/log. */ +const serverPassword = () => process.env.OPENCODE_SERVER_PASSWORD ?? '' +const authHeaders = () => { + if (!serverPassword()) return {} + const username = process.env.OPENCODE_SERVER_USERNAME || 'opencode' + const token = Buffer.from(`${username}:${serverPassword()}`).toString('base64') + return { authorization: `Basic ${token}` } +} + +const sleep = (ms) => new Promise((done) => setTimeout(done, ms)) + +/** + * Probe `/global/health`. Distinguishes "down" (null) from "up but this shell + * has no credential" ({ authRequired: true }) — the caller can't assume a 401 + * is a different server. + */ +const serverHealth = async (url) => { + try { + const response = await fetch(`${url}/global/health`, { + headers: authHeaders(), + signal: AbortSignal.timeout(1500), + }) + if (response.status === 401) return { authRequired: true } + if (!response.ok) return null + return await response.json() + } catch { + return null + } +} + +const cliOpencodeVersion = () => { + const result = spawnSync('opencode', ['--version'], { encoding: 'utf8' }) + return result.status === 0 ? result.stdout.trim() : null +} + +const waitForHealth = async (url, timeoutMs) => { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + const health = await serverHealth(url) + if (health?.healthy) return health + await sleep(250) + } + return null +} + +/** + * Spawn destacado com stdio em fd (precedente `scripts/auto-unblock.mjs`): o + * processo sobrevive ao CLI; só o sucesso do `spawn` é confirmado no pai. + * Lança (não `die`) para o caller preservar cleanup (lock/fd) antes de sair. + * @returns {Promise} pid do filho + */ +const spawnDetached = async (command, args, { cwd, logPath, label }) => { + mkdirSync(dirname(logPath), { recursive: true }) + const logFd = openSync(logPath, 'a') + const child = spawn(command, args, { + cwd, + detached: true, + stdio: ['ignore', logFd, logFd], + }) + try { + await new Promise((resolveSpawn, rejectSpawn) => { + child.once('spawn', resolveSpawn) + child.once('error', rejectSpawn) + }) + } catch (error) { + closeSync(logFd) + throw new Error(`não consegui iniciar ${label}: ${error?.message ?? error}`) + } + child.unref() + closeSync(logFd) + return child.pid +} + +/** + * Ensure the shared server is up and usable. Reuses a healthy one (idempotent); + * otherwise validates the bind (fail-closed beyond loopback), spawns it + * detached with a log, waits for health and records `server.json`. + */ +const ensureServer = async ({ hostname = null, port = null, sessionDir }) => { + const config = resolveServerConfig({ hostname, port }) + // Fail-closed ANTES do reuse: um servidor já de pé não pode transformar um + // bind fora do loopback num caminho sem credencial. + validateServerBind({ hostname: config.hostname, password: serverPassword() }) + const existing = await serverHealth(config.url) + if (existing?.healthy) return { ...config, version: existing.version ?? null, started: false } + if (existing?.authRequired) { + die( + `o servidor em ${config.url} exige credencial — exporte OPENCODE_SERVER_PASSWORD (a mesma da sessão que o iniciou).`, + ) + } + + const logPath = join(sessionDir, 'server.log') + let pid + try { + pid = await spawnDetached( + 'opencode', + serverArgs({ port: config.port, hostname: config.hostname }), + { + cwd: sessionDir, + logPath, + label: `'opencode serve' (log: ${logPath})`, + }, + ) + } catch (error) { + die(error.message) + } + + const health = await waitForHealth(config.url, 15_000) + if (!health?.healthy) { + die( + `o servidor não ficou saudável em ${config.url} — veja ${logPath} (porta ocupada? use TEQO_AGENT_SERVER_PORT=).`, + ) + } + writeFileSync( + serverStatePath({ sessionDir }), + serializeServerState({ + version: STATE_VERSION, + url: config.url, + hostname: config.hostname, + port: config.port, + pid, + startedAt: new Date().toISOString(), + logPath, + }), + ) + return { ...config, version: health.version ?? null, started: true } +} + +const warnVersionDrift = (serverVersion) => { + const cliVersion = cliOpencodeVersion() + if (serverVersion && cliVersion && serverVersion !== cliVersion) { + console.warn( + `[agent:session] aviso: servidor em opencode ${serverVersion} e CLI em ${cliVersion} — atualize o CLI ou reinicie o servidor se algo divergir.`, + ) + } +} + +/** + * JSON request against the server; `null` on connection failure. `tolerant` + * devolve `null` também para 401/5xx — é o modo do `abort` (o `stop` encerra + * localmente mesmo sem servidor/credencial); o default morre alto. + */ +const request = async ( + url, + path, + { method = 'GET', body, timeoutMs = 10_000, tolerant = false } = {}, +) => { + let response + try { + response = await fetch(`${url}${path}`, { + method, + headers: { + ...authHeaders(), + ...(body === undefined ? {} : { 'content-type': 'application/json' }), + }, + body: body === undefined ? undefined : JSON.stringify(body), + signal: AbortSignal.timeout(timeoutMs), + }) + } catch { + return null + } + if (response.status === 401) { + if (tolerant) return null + die( + `o servidor em ${url} exige credencial — exporte OPENCODE_SERVER_PASSWORD (a mesma da sessão que o iniciou).`, + ) + } + if (response.status === 404) return { notFound: true } + if (!response.ok) { + if (tolerant) return null + die(`${method} ${path} falhou: HTTP ${response.status}`) + } + if (response.status === 204) return {} + return response.json().catch(() => ({})) +} + +const sessionExists = async (url, sessionID) => { + const result = await request(url, `/session/${sessionID}`) + return Boolean(result && !result.notFound) +} + +const sessionStatuses = async (url) => (await request(url, '/session/status')) ?? {} + +const createSession = async (url, dir) => { + const result = await request(url, `/session?directory=${encodeURIComponent(dir)}`, { + method: 'POST', + body: {}, + }) + if (!result?.id) die(`não consegui criar a sessão em ${url} (resposta sem id).`) + return result.id +} + +/** Best-effort: o `stop` encerra o driver local mesmo com servidor fora/401. */ +const abortSession = async (url, sessionID) => + (await request(url, `/session/${sessionID}/abort`, { + method: 'POST', + body: {}, + tolerant: true, + })) !== null + +const pidAlive = (pid) => { + if (!Number.isInteger(pid) || pid <= 0) return false + try { + process.kill(pid, 0) + return true + } catch { + return false + } +} + +/** + * O PID só vale como driver se AINDA for um processo opencode: um estado antigo + * pode apontar para um PID reciclado, e `stop`/`list` não podem sinalizar (nem + * marcar como vivo) um processo alheio. Em ambientes sem `/proc` (não-Linux) a + * verificação é inconclusiva — assume vivo, preservando o comportamento. + */ +const driverAlive = (pid, sessionID = null) => { + if (!pidAlive(pid)) return false + try { + const cmdline = readFileSync(`/proc/${pid}/cmdline`, 'utf8') + // O driver é `opencode run --attach … -s `: exigir o sessionID + // impede confundir o run com o `serve` compartilhado, outro run, o + // `opencode web` ou o ACP do Zed num PID reciclado. Sem sessionID, o + // par opencode+run é o melhor filtro disponível. + if (sessionID) return cmdline.includes(sessionID) + return cmdline.includes('opencode') && cmdline.includes('run') + } catch { + return true + } +} + +/** SIGTERM → grace → SIGKILL. Returns true when a process was signalled. */ +const stopDriver = async (pid, { sessionID = null, graceMs = 5000 } = {}) => { + if (!driverAlive(pid, sessionID)) return false + try { + process.kill(pid, 'SIGTERM') + } catch { + return false + } + const deadline = Date.now() + graceMs + while (Date.now() < deadline) { + if (!driverAlive(pid, sessionID)) return true + await sleep(100) + } + try { + process.kill(pid, 'SIGKILL') + } catch { + // already gone + } + return true +} + +const branchOfDir = (dir) => { + const result = spawnSync('git', ['-C', dir, 'rev-parse', '--abbrev-ref', 'HEAD'], { + encoding: 'utf8', + }) + const branch = result.status === 0 ? result.stdout.trim() : '' + if (!branch || branch === 'HEAD') { + die(`não consegui resolver o branch de ${dir} — rode dentro de um worktree do repo.`) + } + return branch +} + +const loadStates = (sessionDir) => { + if (!existsSync(sessionDir)) return [] + const states = [] + for (const file of readdirSync(sessionDir)) { + if (!file.endsWith('.json') || file === 'server.json') continue + try { + states.push(parseSessionState(readFileSync(join(sessionDir, file), 'utf8'))) + } catch (error) { + console.warn(`[agent:session] ignorando ${file}: ${error.message}`) + } + } + return states.sort((left, right) => String(right.startedAt).localeCompare(String(left.startedAt))) +} + +const writeState = (sessionDir, state) => { + mkdirSync(sessionDir, { recursive: true }) + writeFileSync( + sessionStatePath({ sessionDir, branch: state.branch }), + serializeSessionState(state), + ) + return state +} + +const attach = (state) => { + const result = spawnSync( + 'opencode', + attachArgs({ url: state.url, sessionID: state.sessionID, dir: state.dir }), + { cwd: state.dir, stdio: 'inherit' }, + ) + return result.status ?? 0 +} + +/** `--session` > `--branch` > `--issue` > branch do cwd (o cwd só é lido se preciso). */ +const resolveRef = ({ flags, sessionDir }) => { + const states = loadStates(sessionDir) + const issue = typeof flags.issue === 'string' ? Number(flags.issue) : null + const cwdBranch = flags.session || flags.branch || issue ? null : branchOfDir(process.cwd()) + const state = resolveSessionRef({ + states, + session: flags.session ?? null, + branch: flags.branch ?? null, + issue, + cwdBranch, + }) + if (!state) { + const refs = [ + flags.session && `--session=${flags.session}`, + flags.branch && `--branch=${flags.branch}`, + flags.issue && `--issue=${flags.issue}`, + cwdBranch && `branch ${cwdBranch}`, + ].filter(Boolean) + die( + `nenhuma sessão encontrada (${refs.join(', ') || 'sem referência'}) — veja \`pnpm agent:session list\`.`, + ) + } + return state +} + +/** Single-flight per branch: two `start`s must not create two drivers/sessions. */ +const acquireStartLock = ({ sessionDir, branch }) => { + mkdirSync(sessionDir, { recursive: true }) + const lockPath = startLockPath({ sessionDir, branch }) + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + const fd = openSync(lockPath, 'wx') + writeFileSync(fd, `${process.pid}\n`) + return () => { + try { + closeSync(fd) + } catch { + // already closed + } + try { + unlinkSync(lockPath) + } catch { + // already gone + } + } + } catch (error) { + if (error?.code !== 'EEXIST') throw error + let holder = null + try { + holder = Number(readFileSync(lockPath, 'utf8').trim()) + } catch { + // unreadable lock — treat as stale below + } + if (!pidAlive(holder)) { + try { + unlinkSync(lockPath) + } catch { + // raced + } + continue + } + die( + `outro 'start' está em andamento para ${branch} (pid ${holder}) — aguarde ou remova ${lockPath}.`, + ) + } + } + die(`não consegui travar o start de ${branch} (${lockPath}) — tente de novo.`) +} + +const cmdServe = async (flags) => { + const sessionDir = sessionDirFromEnv() + const server = await ensureServer({ + hostname: flags.hostname ?? null, + port: flags.port ?? null, + sessionDir, + }) + warnVersionDrift(server.version) + console.log( + `${server.started ? 'servidor iniciado' : 'servidor já está de pé'}: ${server.url}${server.version ? ` (opencode ${server.version})` : ''}`, + ) + console.log(` estado: ${sessionDir}`) +} + +const cmdStart = async (flags) => { + const dir = resolve(flags.dir ?? process.cwd()) + const purpose = flags.purpose ?? 'new' + const sessionDir = sessionDirFromEnv() + const branch = branchOfDir(dir) + const model = flags.model ?? null + const issueNumber = typeof flags.issue === 'string' ? Number(flags.issue) : null + const invocation = purposeInvocation({ + purpose, + issueNumber, + argument: flags.argument ?? null, + }) + if (invocation && !model) die('--model é obrigatório para iniciar o run (driver).') + + const server = await ensureServer({ + hostname: flags.hostname ?? null, + port: flags.port ?? null, + sessionDir, + }) + warnVersionDrift(server.version) + + const statePath = sessionStatePath({ sessionDir, branch }) + const readReusable = async () => { + if (flags.new || !existsSync(statePath)) return null + let existing + try { + existing = parseSessionState(readFileSync(statePath, 'utf8')) + } catch (error) { + console.warn( + `[agent:session] estado ilegível em ${statePath} (${error.message}) — tratando como sessão nova.`, + ) + return null + } + if (existing.url !== server.url) return null + const busy = (await sessionStatuses(server.url))[existing.sessionID]?.type === 'busy' + return driverAlive(existing.driverPid, existing.sessionID) || busy ? existing : null + } + + // A decisão de reuso acontece DENTRO do lock: dois `start`s concorrentes do + // mesmo branch não podem ambos ler "não vivo" e criar dois drivers/sessões. + const release = acquireStartLock({ sessionDir, branch }) + let state + let reused = false + try { + const existing = await readReusable() + if (existing) { + reused = true + state = existing + } else { + const sessionID = await createSession(server.url, dir) + state = { + version: STATE_VERSION, + code: codeFromBranch(branch), + issue: Number.isInteger(issueNumber) && issueNumber > 0 ? issueNumber : null, + purpose, + branch, + dir, + sessionID, + url: server.url, + model, + driverPid: null, + logPath: driverLogPath({ sessionDir, branch }), + startedAt: new Date().toISOString(), + stoppedAt: null, + } + if (invocation) { + state.driverPid = await spawnDetached( + 'opencode', + driverArgs({ url: server.url, sessionID, dir, model, invocation }), + { cwd: dir, logPath: state.logPath, label: `o driver do run (log: ${state.logPath})` }, + ) + } else if (purpose !== 'new') { + console.warn( + `[agent:session] purpose=${purpose} sem comando/issue válida — sessão criada sem driver.`, + ) + } + writeState(sessionDir, state) + } + } finally { + // Released BEFORE the attach: o lock só protege a criação (dois `start`s + // não podem criar dois drivers/sessões), nunca a duração do TUI. + release() + } + + if (reused) { + console.log(`run já em andamento para ${branch} — anexando (${state.sessionID}).`) + } else { + console.log(`sessão ${state.sessionID} em ${server.url} (${purpose}) — log: ${state.logPath}`) + } + console.log('sair do TUI não encerra o run; encerrar: `pnpm agent:session stop`') + return attach(state) +} + +const cmdAttach = async (flags) => { + const sessionDir = sessionDirFromEnv() + const state = resolveRef({ flags, sessionDir }) + const serverUrl = new URL(state.url) + await ensureServer({ + hostname: serverUrl.hostname, + port: Number(serverUrl.port), + sessionDir, + }) + if (!(await sessionExists(state.url, state.sessionID))) { + die( + `a sessão ${state.sessionID} não existe mais no servidor ${state.url} — inicie um novo run em ${state.dir}.`, + ) + } + return attach(state) +} + +const cmdStop = async (flags) => { + const sessionDir = sessionDirFromEnv() + const state = resolveRef({ flags, sessionDir }) + const aborted = await abortSession(state.url, state.sessionID) + if (!aborted) { + console.warn( + `[agent:session] não consegui falar com o servidor ${state.url} — encerrando o driver local mesmo assim.`, + ) + } + const killed = await stopDriver(state.driverPid, { sessionID: state.sessionID }) + writeState(sessionDir, { ...state, driverPid: null, stoppedAt: new Date().toISOString() }) + console.log( + `run encerrado: ${state.sessionID} (${state.branch})${aborted ? '' : ' [abort offline]'}${killed ? '' : ' [driver já havia saído]'}`, + ) +} + +/** + * Onde o `list` sonda: `server.json` (gravado pelo `serve`, inclusive com + * `--port/--hostname` explícitos) > env/flags > defaults. + */ +const serverConfigForList = ({ sessionDir, flags }) => { + const statePath = serverStatePath({ sessionDir }) + if (flags.hostname || flags.port || !existsSync(statePath)) { + return resolveServerConfig({ hostname: flags.hostname ?? null, port: flags.port ?? null }) + } + try { + const server = parseServerState(readFileSync(statePath, 'utf8')) + return { hostname: server.hostname, port: server.port, url: server.url } + } catch (error) { + console.warn(`[agent:session] server.json ilegível (${error.message}) — usando o default.`) + return resolveServerConfig({}) + } +} + +const cmdList = async (flags) => { + const sessionDir = sessionDirFromEnv() + const states = loadStates(sessionDir) + const config = serverConfigForList({ sessionDir, flags }) + const health = await serverHealth(config.url) + const serverHealthy = Boolean(health?.healthy) + const statuses = serverHealthy ? await sessionStatuses(config.url) : {} + const derived = {} + for (const state of states) { + derived[state.sessionID] = deriveSessionStatus({ + state, + serverHealthy, + sessionBusy: statuses[state.sessionID]?.type === 'busy', + driverAlive: driverAlive(state.driverPid, state.sessionID), + }) + } + if (flags.json) { + console.log( + JSON.stringify( + sessionListPayload({ + states, + statuses: derived, + server: { + url: config.url, + healthy: serverHealthy, + version: health?.version ?? null, + }, + }), + null, + 2, + ), + ) + return + } + if (states.length === 0) { + console.log(`nenhuma sessão registrada em ${sessionDir}`) + return + } + console.log('STATUS REF SESSÃO BRANCH DIR') + for (const line of formatSessionList( + states.map((state) => ({ ...state, status: derived[state.sessionID] })), + )) { + console.log(line) + } + if (!serverHealthy) { + console.log( + health?.authRequired + ? '(servidor de pé mas exige credencial: exporte OPENCODE_SERVER_PASSWORD)' + : `(servidor fora: ${config.url} — suba com \`pnpm agent:session serve\`)`, + ) + } +} + +const { flags, positional } = parseEqualsFlags(process.argv.slice(2)) +const subcommand = positional[0] + +try { + if (positional.length > 1) { + die( + `argumento posicional inesperado: ${positional.slice(1).join(' ')} (use --flag=valor; veja o USAGE).`, + ) + } + if (subcommand === 'serve') await cmdServe(flags) + else if (subcommand === 'start') { + process.exitCode = await cmdStart(flags) + } else if (subcommand === 'attach') { + process.exitCode = await cmdAttach(flags) + } else if (subcommand === 'stop') await cmdStop(flags) + else if (subcommand === 'list') await cmdList(flags) + else { + console.log(USAGE) + process.exitCode = 1 + } +} catch (error) { + if (error?.stderr) die(error.stderr.toString().trim()) + die(error?.message ?? String(error)) +} diff --git a/scripts/lib/agent-session.mjs b/scripts/lib/agent-session.mjs new file mode 100644 index 00000000..214ad9d0 --- /dev/null +++ b/scripts/lib/agent-session.mjs @@ -0,0 +1,323 @@ +/** + * OPS110 — sessões persistentes de agente (attach/detach). + * + * Pura por contrato: nada aqui faz I/O além de resolver paths (o CLI + * `scripts/agent-session.mjs` é quem spawna/HTTP). O ciclo: um `opencode serve` + * compartilhado (loopback por padrão) é dono das sessões; cada run é dirigido + * por um `opencode run --attach … --auto --command …` destacado (é o `--auto` + * dele que aprova permissões enquanto o run vive) e observado por um TUI + * `opencode attach -s ` que pode entrar e sair à vontade — sair do + * cliente nunca encerra o run; encerrar é `stop` (abort via API). + * + * O estado por run (`sessionStatePath`) é o índice de endereçamento consumido + * pelo painel OPS109 (`list --json`): branch/dir/sessionID/url/driverPid/log. + * Nenhuma credencial é persistida — `OPENCODE_SERVER_PASSWORD`/ + * `OPENCODE_SERVER_USERNAME` vivem só no ambiente; bind fora do loopback sem + * senha falha fechado (`validateServerBind`). + */ +import { homedir } from 'node:os' +import { join } from 'node:path' + +import { slugify } from '../../src/lib/slug.ts' + +/** Default host of the shared agent server — loopback only, never `0.0.0.0`. */ +export const DEFAULT_AGENT_SERVER_HOST = '127.0.0.1' + +/** + * Default port. Fora da faixa de dev dos worktrees (`3100+slot`, slot ≤ 999 → + * até 4099) e longe dos servidores que a máquina já pode ter (4096 Zed ACP, + * 4097 `opencode web` do usuário). `TEQO_AGENT_SERVER_PORT` overrides. + */ +export const DEFAULT_AGENT_SERVER_PORT = 4199 + +/** State-file schema version — `list --json` carries it for OPS109. */ +export const STATE_VERSION = 1 + +/** + * Resolve host/port/url for the shared server: explicit flags (`serve + * --hostname/--port`) > env (`TEQO_AGENT_SERVER_HOST/PORT`) > defaults. Fail + * high on a non-numeric/out-of-range port — never guess a listening address. + * @param {{ hostname?: string | null, port?: number | string | null, env?: Record }} [options] + */ +export const resolveServerConfig = ({ hostname = null, port = null, env = process.env } = {}) => { + const resolvedHostname = hostname ?? env.TEQO_AGENT_SERVER_HOST ?? DEFAULT_AGENT_SERVER_HOST + const rawPort = port ?? env.TEQO_AGENT_SERVER_PORT ?? DEFAULT_AGENT_SERVER_PORT + const resolvedPort = Number(rawPort) + if (!Number.isInteger(resolvedPort) || resolvedPort < 1 || resolvedPort > 65535) { + throw new Error(`porta inválida para o servidor de sessões: ${rawPort}`) + } + return { + hostname: resolvedHostname, + port: resolvedPort, + url: `http://${resolvedHostname}:${resolvedPort}`, + } +} + +/** + * Loopback spellings only — `0.0.0.0`, hostnames that merely START with `127.` + * (`127.0.0.1.example.com`) and any routable address are NOT loopback. + */ +export const isLoopbackHost = (hostname) => { + const host = String(hostname ?? '') + .trim() + .toLowerCase() + .replace(/^\[|\]$/g, '') + if (host === 'localhost' || host === '::1') return true + return /^127(\.\d{1,3}){3}$/.test(host) +} + +/** + * Fail-closed bind policy: anything beyond loopback requires the opencode + * server credential (`OPENCODE_SERVER_PASSWORD`); loopback may run without it. + * Returns true on success so callers can gate the spawn on the exception. + */ +export const validateServerBind = ({ hostname, password }) => { + if (isLoopbackHost(hostname)) return true + if (typeof password === 'string' && password.length > 0) return true + throw new Error( + `bind em ${hostname} exige credencial: defina OPENCODE_SERVER_PASSWORD (fail-closed fora do loopback)`, + ) +} + +/** + * State dir: `TEQO_AGENT_SESSION_DIR` > `$XDG_STATE_HOME/teqo/agent-sessions` > + * `~/.local/state/teqo/agent-sessions`. + * @param {Record} [env] + */ +export const sessionDirFromEnv = (env = process.env) => { + if (env.TEQO_AGENT_SESSION_DIR) return env.TEQO_AGENT_SESSION_DIR + const stateHome = env.XDG_STATE_HOME || join(env.HOME || homedir(), '.local', 'state') + return join(stateHome, 'teqo', 'agent-sessions') +} + +/** One filename-safe key per branch — `plans/plan-issue-1` → `plans-plan-issue-1`. */ +export const sessionSlug = (branch) => slugify(String(branch ?? '')) + +/** `-` claim branch → code; namespace branches (`plans/`, `work/`, `fix/`) → null. */ +export const codeFromBranch = (branch) => { + const match = /^([A-Z][A-Za-z0-9]*)-/.exec(String(branch ?? '')) + return match ? match[1] : null +} + +export const sessionStatePath = ({ sessionDir, branch }) => + join(sessionDir, `${sessionSlug(branch)}.json`) + +export const serverStatePath = ({ sessionDir }) => join(sessionDir, 'server.json') + +export const startLockPath = ({ sessionDir, branch }) => + join(sessionDir, `${sessionSlug(branch)}.lock`) + +export const driverLogPath = ({ sessionDir, branch }) => + join(sessionDir, `${sessionSlug(branch)}.log`) + +const assertSessionState = (state) => { + const invalid = (reason) => { + throw new Error(`estado de sessão inválido: ${reason}`) + } + if (!state || typeof state !== 'object' || Array.isArray(state)) invalid('não é objeto') + if (!Number.isInteger(state.version) || state.version < 1) invalid('version') + if (typeof state.sessionID !== 'string' || !state.sessionID.startsWith('ses')) + invalid('sessionID') + if (typeof state.branch !== 'string' || state.branch.length === 0) invalid('branch') + if (typeof state.dir !== 'string' || state.dir.length === 0) invalid('dir') + if (typeof state.url !== 'string' || !state.url.startsWith('http')) invalid('url') + return state +} + +export const serializeSessionState = (state) => + `${JSON.stringify(assertSessionState(state), null, 2)}\n` + +export const parseSessionState = (text) => { + let parsed + try { + parsed = JSON.parse(text) + } catch { + throw new Error('estado de sessão inválido: JSON ilegível') + } + return assertSessionState(parsed) +} + +const assertServerState = (state) => { + const invalid = (reason) => { + throw new Error(`estado do servidor inválido: ${reason}`) + } + if (!state || typeof state !== 'object' || Array.isArray(state)) invalid('não é objeto') + if (typeof state.url !== 'string' || !state.url.startsWith('http')) invalid('url') + if (!Number.isInteger(state.port)) invalid('port') + if (!Number.isInteger(state.pid) || state.pid <= 0) invalid('pid') + return state +} + +export const serializeServerState = (state) => + `${JSON.stringify(assertServerState(state), null, 2)}\n` + +export const parseServerState = (text) => { + let parsed + try { + parsed = JSON.parse(text) + } catch { + throw new Error('estado do servidor inválido: JSON ilegível') + } + return assertServerState(parsed) +} + +/** + * Skill command per launch purpose — the terminal launch auto-submits it on + * the server-owned session. `new` deliberately has none ("apenas conversar"): + * the session is created and attached, no driver. + */ +export const SESSION_COMMAND_BY_PURPOSE = { + next: 'work-issue', + plan: 'plan-issue', + new: null, + fix: 'bug-fix', +} + +/** + * `{ command, arguments }` for the purpose, or null when there is nothing to + * auto-submit. `next` carries only a valid claimed issue (`--issue N`), `fix` + * carries the sanitized bag (quotes/backslashes stripped — the value rides a + * single argv token), `plan` sends the bare command, unknown purposes degrade + * to no command (fail-safe, mirrors the old directive). + * @param {{ purpose?: string, issueNumber?: number | string | null, argument?: string | null }} [options] + */ +export const purposeInvocation = ({ purpose = null, issueNumber = null, argument = null } = {}) => { + const command = SESSION_COMMAND_BY_PURPOSE[purpose] ?? null + if (!command) return null + if (purpose === 'next') { + const number = Number(issueNumber) + if (!Number.isInteger(number) || number <= 0) return null + return { command, arguments: `--issue ${number}` } + } + if (purpose === 'fix') { + // Mesma sanitização da diretiva de launch (fronteira dupla de propósito: + // o xargs da camada shell e o argv do `opencode run` não honram escapes). + const sanitized = typeof argument === 'string' ? argument.replace(/["\\]/g, '').trim() : '' + return { command, arguments: sanitized } + } + return { command, arguments: '' } +} + +/** + * argv of the detached run driver (sem o binário — o caller usa + * `spawn('opencode', …)`). The `--` separator is required so a command + * argument starting with `-` (`--issue 1019`) is not parsed as an opencode flag + * by yargs — verified live on 1.18.31. No `-p`: the credential rides + * `OPENCODE_SERVER_PASSWORD` in the inherited env (never in `ps`). + * @param {{ url: string, sessionID: string, dir: string, model: string, invocation: { command: string, arguments?: string } | null }} options + */ +export const driverArgs = ({ url, sessionID, dir, model, invocation }) => { + if (typeof url !== 'string' || url.length === 0) throw new Error('driverArgs: url ausente') + if (typeof sessionID !== 'string' || sessionID.length === 0) { + throw new Error('driverArgs: session ausente') + } + if (typeof dir !== 'string' || dir.length === 0) throw new Error('driverArgs: dir ausente') + if (typeof model !== 'string' || model.length === 0) throw new Error('driverArgs: model ausente') + if (!invocation || typeof invocation.command !== 'string' || invocation.command.length === 0) { + throw new Error('driverArgs: invocation/comando ausente') + } + const argv = [ + 'run', + '--attach', + url, + '-s', + sessionID, + '--dir', + dir, + '--model', + model, + '--auto', + '--command', + invocation.command, + ] + if (invocation.arguments) argv.push('--', invocation.arguments) + return argv +} + +/** + * Args do TUI anexado, SEM o binário (`opencode attach` não tem + * `--auto`/`--model` por design; o caller usa `spawn('opencode', …)`). + */ +export const attachArgs = ({ url, sessionID, dir }) => { + if (typeof url !== 'string' || url.length === 0) throw new Error('attachArgs: url ausente') + if (typeof sessionID !== 'string' || sessionID.length === 0) { + throw new Error('attachArgs: session ausente') + } + if (typeof dir !== 'string' || dir.length === 0) throw new Error('attachArgs: dir ausente') + return ['attach', url, '--dir', dir, '-s', sessionID] +} + +/** Args do servidor headless, SEM o binário. Nunca `--mdns` (anunciaria na rede). */ +export const serverArgs = ({ port, hostname }) => { + if (!Number.isInteger(port)) throw new Error('serverArgs: porta inválida') + if (typeof hostname !== 'string' || hostname.length === 0) { + throw new Error('serverArgs: hostname ausente') + } + return ['serve', '--port', String(port), '--hostname', hostname] +} + +/** + * Pick the state a command refers to — `--session` > `--branch` > `--issue` > + * the current branch. Null when nothing matches (callers print the known refs). + * @param {{ states: Array>, session?: string | null, branch?: string | null, issue?: number | string | null, cwdBranch?: string | null }} options + */ +export const resolveSessionRef = ({ + states, + session = null, + branch = null, + issue = null, + cwdBranch = null, +}) => { + const list = Array.isArray(states) ? states : [] + if (session) { + const hit = list.find((state) => state.sessionID === session) + if (hit) return hit + } + if (branch) { + const hit = list.find((state) => state.branch === branch) + if (hit) return hit + } + const number = issue === null || issue === '' ? null : Number(issue) + if (Number.isInteger(number) && number > 0) { + const hit = list.find((state) => state.issue === number) + if (hit) return hit + } + if (cwdBranch) { + const hit = list.find((state) => state.branch === cwdBranch) + if (hit) return hit + } + return null +} + +/** + * Status shown by `list`: `stopped` (explicit stop) wins, then `unknown` + * (server unreachable), then `working` (busy on the server or driver alive), + * else `idle`. + */ +export const deriveSessionStatus = ({ state, serverHealthy, sessionBusy, driverAlive }) => { + if (state?.stoppedAt) return 'stopped' + if (!serverHealthy) return 'unknown' + if (sessionBusy || driverAlive) return 'working' + return 'idle' +} + +/** + * Versioned machine contract of `list --json` (OPS109 consumes it): + * `{ version, server, sessions: [{...state, status}] }`. + */ +export const sessionListPayload = ({ states, statuses = {}, server }) => ({ + version: STATE_VERSION, + server, + sessions: (Array.isArray(states) ? states : []).map((state) => ({ + ...state, + status: statuses[state.sessionID] ?? 'unknown', + })), +}) + +/** Human `list` lines — one per run, status first, `—` for namespace runs. */ +export const formatSessionList = (rows) => + (Array.isArray(rows) ? rows : []).map((row) => { + const reference = + [row.code, row.issue ? `#${row.issue}` : null].filter(Boolean).join(' ') || '—' + return `${String(row.status).padEnd(8)} ${reference} ${row.sessionID} ${row.branch} ${row.dir}` + }) diff --git a/scripts/lib/worktree.mjs b/scripts/lib/worktree.mjs index 270caead..a9614c6f 100644 --- a/scripts/lib/worktree.mjs +++ b/scripts/lib/worktree.mjs @@ -77,24 +77,6 @@ export const resolveWorktreeModel = (flags = {}) => { return OPENCODE_PRESET_MODEL } -/** - * Skill command sent as the launch's initial message per purpose. `next` - * sends `/work-issue` (the OPS25 command executes the full cycle; the OPS33 - * launch appends `--issue ` — the claimed issue — via - * `opencodeLaunchDirective`); `plan` sends `/plan-issue` (OPS31 — auto-submit - * is the real need of the flow), `fix` sends `/bug-fix` (with the bug - * description as the argument) and `new` sends nothing ("apenas conversar", - * no skill at all). The opencode CLI's `--prompt` always auto-submits, there - * is no prefill-without-submit flag; prefill stays an upstream-only feature - * request (proposition annotated), not a Teqo fallback. - */ -export const OPENCODE_SKILL_COMMAND_BY_PURPOSE = { - next: '/work-issue', - plan: '/plan-issue', - new: null, - fix: '/bug-fix', -} - /** * Prefix of every neutral-worktree branch (`pnpm worktree new`). Lowercase-led * `work/…` — structurally disjoint from `next`'s uppercase-led `-` @@ -188,21 +170,32 @@ const namespaceBranchName = ({ prefix, bag = '', taken = new Set(), fallback }) /** * Launch directive for the opencode TUI, printed by `worktree next`/`plan` * right before the `cd ` line when called from the interactive terminal - * (`WORKTREE_TERMINAL=1`): `launch opencode --model - * --auto [--prompt ""]` (OPS95: no `--variant` — the TUI yargs - * rejects the unknown flag and the helper prints instead of opening; modelo - * vem do mapa `WORKTREE_MODEL_MAP` quando a flag `--cheap/--pro/--zen/ - * --go/--alibaba/--glm/--free` está presente, senão do preset). The shell function - * (`.agents/shell/worktree.sh`) applies the `cd` first, then tokenizes and - * executes this line (xargs — quote-aware, never eval) — the dir is always - * `/`, so the line never needs quoting - * for the path. `next` with an `issueNumber` sends `/work-issue --issue ` + * (`WORKTREE_TERMINAL=1`): `launch node scripts/agent-session.mjs start + * --purpose=

--dir=

--model= [--issue=] + * [--argument=""]` (OPS110 — o launch deixa de abrir um TUI local dono da + * sessão e passa a delegar ao `scripts/agent-session.mjs`, que sobe/anexa no + * servidor compartilhado: o run sobrevive ao fechamento do terminal e pode ser + * reatado de outro dispositivo). O mapa purpose→skill e a montagem do comando + * vivem no dono do ciclo de vida (`scripts/lib/agent-session.mjs`, + * `purposeInvocation`). OPS95 continua: sem `--variant`; modelo vem do mapa + * `WORKTREE_MODEL_MAP` quando a flag `--cheap/--pro/--zen/--go/--alibaba/--glm/--free` + * está presente, senão do preset. + * + * The shell function (`.agents/shell/worktree.sh`) applies the `cd` first, then + * tokenizes and executes this line (xargs — quote-aware, never eval) — the dir + * is always `/`, so the line never needs + * quoting for the path. `next` with an `issueNumber` carries `--issue=` * (OPS33: the launch delivers the claimed issue to the agent; the skill reads - * the rest from GitHub), `fix` with an `argument` sends `/bug-fix ` - * (the bug description arrives with the skill), the prompt value is quoted in - * the line because it now carries a space. Returns `null` outside the terminal - * so the `/worktree` opencode command never launches a nested TUI. - * @param {{ dir: string, purpose: string, terminal?: boolean, issueNumber?: number | null, model?: string | null, argument?: string | null }} options + * the rest from GitHub), `fix` with an `argument` carries `--argument=""` + * (the bug description arrives with the skill; quotes/backslashes are stripped + * because the shell layer tokenizes with xargs, which does not honor + * backslash escapes). Returns `null` outside the terminal so the `/worktree` + * opencode command never launches a nested TUI. + * `sessionScript` é o caminho do CLI de sessão — `worktree.mjs` passa o + * ABSOLUTO do checkout que emitiu a diretiva (worktrees reabertos/criados + * antes do merge não têm o arquivo novo; o relativo apontaria para o branch + * errado). O default relativo existe para os specs. + * @param {{ dir: string, purpose: string, terminal?: boolean, issueNumber?: number | null, model?: string | null, argument?: string | null, sessionScript?: string }} options */ export const opencodeLaunchDirective = ({ dir, @@ -211,25 +204,29 @@ export const opencodeLaunchDirective = ({ issueNumber = null, model = null, argument = null, + sessionScript = 'scripts/agent-session.mjs', }) => { if (!terminal) return null - const prompt = OPENCODE_SKILL_COMMAND_BY_PURPOSE[purpose] const selectedModel = model ?? OPENCODE_PRESET_MODEL - const args = [dir, '--model', selectedModel, '--auto'] - if (prompt) { - // The issue suffix belongs to `next` alone — `plan`/`new`/`fix` never carry - // a claimed issue (fail-safe: a stray issueNumber must not break them). - const issueSuffix = purpose === 'next' && issueNumber ? `--issue ${issueNumber}` : null - // The bag suffix belongs to `fix` alone — the bug description arrives with - // the skill. Quotes/backslashes are stripped: the shell layer tokenizes the - // directive with xargs, which does not honor backslash escapes. - const sanitized = typeof argument === 'string' ? argument.replace(/["\\]/g, '').trim() : null - const bagSuffix = purpose === 'fix' && sanitized ? sanitized : null - const value = [prompt, issueSuffix, bagSuffix].filter(Boolean).join(' ') - // JSON.stringify quotes the value — the directive carries spaces now. - args.push('--prompt', JSON.stringify(value)) + const args = [ + 'node', + sessionScript, + 'start', + `--purpose=${purpose}`, + `--dir=${dir}`, + `--model=${selectedModel}`, + ] + // The issue suffix belongs to `next` alone — `plan`/`new`/`fix` never carry + // a claimed issue (fail-safe: a stray issueNumber must not break them). + if (purpose === 'next' && issueNumber) args.push(`--issue=${issueNumber}`) + // The bag suffix belongs to `fix` alone — the bug description arrives with + // the skill. JSON.stringify quotes the value (it carries spaces); xargs + // strips the quotes at execution time. + if (purpose === 'fix') { + const sanitized = typeof argument === 'string' ? argument.replace(/["\\]/g, '').trim() : '' + if (sanitized) args.push(`--argument=${JSON.stringify(sanitized)}`) } - return `launch opencode ${args.join(' ')}` + return `launch ${args.join(' ')}` } /** diff --git a/scripts/worktree.mjs b/scripts/worktree.mjs index f99738c6..9379eb41 100644 --- a/scripts/worktree.mjs +++ b/scripts/worktree.mjs @@ -30,18 +30,23 @@ * `deepseek/deepseek-flash` permanece. * Chamado do terminal interativo (com * `TEQO_WORKTREE_TERMINAL=1`, que só a função shell - * seta), imprime também a diretiva `launch - * opencode --model - * --auto --prompt "/work-issue --issue "` ANTES - * do `cd` — a função shell executa o cd e então a - * linha, e o TUI do opencode abre no worktree com - * `/work-issue --issue ` já enviado (OPS26 + - * OPS33: o launch entrega a Issue claimada ao - * agente; a skill lê o resto do GitHub). OPS95: - * sem `--variant` na diretiva — o yargs do TUI - * rejeita o flag e só imprime o helper; variantes - * ficam na config global da máquina (Ctrl+T). - * Presets e mapa em scripts/lib/worktree.mjs. + * seta), imprime também a diretiva `launch node + * scripts/agent-session.mjs start --purpose=next + * --dir= --model --issue=` + * ANTES do `cd` — a função shell executa o cd e + * então a linha. Desde a OPS110 o launch não abre um + * TUI local dono da sessão: o `agent-session` sobe + * o `opencode serve` compartilhado, cria a sessão + * endereçável, dispara o driver destacado com + * `/work-issue --issue ` (auto-submit; a skill lê + * o resto do GitHub) e anexa o TUI + * (`opencode attach -s `) — fechar o + * terminal NÃO encerra o run; encerrar é + * `pnpm agent:session stop`. OPS95 continua: sem + * `--variant`; variantes ficam na config global da + * máquina (Ctrl+T). Presets e mapa em + * scripts/lib/worktree.mjs; ciclo de vida em + * scripts/lib/agent-session.mjs. * Sem o marcador (comando `/worktree` do opencode), * a diretiva não é impressa — nunca abre TUI aninhado. * Também PROVISIONA o ambiente isolado do worktree: @@ -71,11 +76,11 @@ * deles (branch nem slot) colide com um `next` * posterior (prefixo minúsculo `plans/…`). * Mesmo provisionamento isolado do `next`; no - * terminal, mesma diretiva `launch` — com - * `--prompt /plan-issue` já enviado (OPS31: o TUI - * abre no fluxo de planejamento, sem digitação) e - * `--model ` quando a flag de modelo está - * presente. + * terminal, mesma diretiva `launch` — o driver + * destacado auto-submete `/plan-issue` (OPS31: a + * sessão abre no fluxo de planejamento, sem + * digitação) e `--model ` quando a flag de + * modelo está presente (OPS110: attach/detach). * pnpm worktree new [bag] [--stay] [--no-migrate] [--cheap|--pro|--zen|--go|--alibaba|--glm|--free] * cria um worktree NEUTRO novo — sem função * pré-definida (explorar ideia, conversar, ou @@ -87,10 +92,9 @@ * nunca colide com `-` de `next` nem * com `plans/plan-issue-…` de `plan`. * Mesmo provisionamento isolado do `next`/`plan`; - * no terminal, mesma diretiva `launch` — SEM - * `--prompt` (apenas conversar, nenhuma skill) e - * `--model ` quando a flag de modelo está - * presente. + * no terminal, mesma diretiva `launch` — sessão + * sem skill (apenas conversar) e `--model ` + * quando a flag de modelo está presente (OPS110). * `--stay` suprime a linha `cd` e a diretiva; * `--cheap/--pro/--zen/--go/--alibaba/--glm/--free` escolhe o * modelo por invocação (sem flag o preset permanece). @@ -106,22 +110,25 @@ * com `plans/plan-issue-…` de `plan` nem com * `work/…` de `new`. Mesmo provisionamento * isolado do `next`/`plan`/`new`; no terminal, a - * diretiva `launch` envia `--prompt - * "/bug-fix "` — a descrição do bug chega ao - * agente junto com a skill (aspas/barra-invertida - * do bag são removidas — o xargs da camada shell - * não honra escapes); `--model ` quando a - * flag de modelo está presente; `--stay` suprime + * diretiva `launch` carrega `--argument=""` + * — o driver destacado auto-submete `/bug-fix + * ` e a descrição do bug chega ao agente + * junto com a skill (aspas/barra-invertida do bag + * são removidas — o xargs da camada shell não + * honra escapes); `--model ` quando a flag de + * modelo está presente (OPS110); `--stay` suprime * cd e launch (nenhuma Issue é claimada nem * criada — o registro do bug é o post-mortem). * pnpm worktree kill [--force] destrói o worktree em que o shell atual está - * (recusa worktree sujo sem `--force`) e remove os - * bancos gerados do worktree (best-effort); por - * padrão termina imprimindo `cd
` para o - * shell voltar ao worktree principal — o cwd nunca - * fica num diretório destruído (não aceita - * `--stay`; sem launch, as flags de modelo são - * irrelevantes) + * (recusa worktree sujo sem `--force`), ENCERRA a + * sessão persistente do run (OPS110: abort + fim + * do driver; o estado/lock são removidos — sem + * sessão órfã) e remove os bancos gerados do + * worktree (best-effort); por padrão termina + * imprimindo `cd
` para o shell voltar ao + * worktree principal — o cwd nunca fica num + * diretório destruído (não aceita `--stay`; sem + * launch, as flags de modelo são irrelevantes) * * Read-only no GitHub? NÃO — desde o OPS33 `next` CLAIMA: claim determinístico * antes do worktree (mesma fila/ordem e lock otimista do `pnpm agent:claim`; @@ -139,10 +146,12 @@ import { readdirSync, readFileSync, statSync, + unlinkSync, writeFileSync, } from 'node:fs' import { homedir } from 'node:os' -import { join, resolve } from 'node:path' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' import { parse as parseEnv } from 'dotenv' import pg from 'pg' @@ -157,6 +166,7 @@ import { nextClaimableIssue, parseArgs, } from './lib/agent-forgejo.mjs' +import { sessionDirFromEnv, sessionStatePath, startLockPath } from './lib/agent-session.mjs' import { startSharedPostgres } from './lib/db-start.mjs' import { githubApi as api } from './lib/github-api.mjs' import { @@ -183,6 +193,12 @@ import { const die = dieAgent('worktree') const WORKTREES_ROOT = process.env.WORKTREES_ROOT ?? join(homedir(), '.cursor', 'worktrees', 'teqo') +/** + * Root deste checkout — o `stopAgentSession` reusa o CLI daqui e a diretiva de + * launch carrega o caminho ABSOLUTO dele (OPS110: worktree antigo não tem o + * arquivo novo no branch). + */ +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') /** * True when the interactive terminal shell function (`.agents/shell/worktree.sh`) @@ -211,6 +227,9 @@ const printLaunchDirective = ({ dir, purpose, issueNumber, argument = null, flag issueNumber, model, argument, + // Absoluto de propósito (OPS110): worktrees reabertos/criados antes do + // merge não têm `scripts/agent-session.mjs`; o relativo quebraria o launch. + sessionScript: join(REPO_ROOT, 'scripts', 'agent-session.mjs'), }) if (line) console.log(line) } @@ -834,6 +853,52 @@ const dropWorktreeDatabases = async (names) => { } } +/** + * OPS110 — encerra a sessão persistente do run deste worktree antes de + * destruí-lo (`stop`: abort via API + fim do driver). Só age quando existe + * estado registrado (worktrees anteriores à OPS110 não têm sessão); o estado e + * o lock são removidos ao fim para não deixar sessão órfã apontando para um + * diretório destruído. + */ +const stopAgentSession = (branch) => { + if (!branch) return + const sessionDir = sessionDirFromEnv() + const statePath = sessionStatePath({ sessionDir, branch }) + const lockPath = startLockPath({ sessionDir, branch }) + if (existsSync(statePath)) { + let stopped = true + try { + execFileSync( + process.execPath, + [join(REPO_ROOT, 'scripts', 'agent-session.mjs'), 'stop', `--branch=${branch}`], + { stdio: 'inherit' }, + ) + } catch (error) { + stopped = false + console.warn( + `[worktree] não consegui encerrar a sessão do run (${String(error.message).split('\n')[0]}); o kill segue.`, + ) + } + // Só descarta o endereço se o stop funcionou — um driver sobrevivente sem + // estado viraria órfão invisível (e o estado fica para o `agent:session`). + if (stopped) { + try { + unlinkSync(statePath) + } catch { + // já removido + } + } else { + console.warn(`[worktree] a sessão pode seguir viva — estado preservado em ${statePath}`) + } + } + // O lock é artefato transitório do `start` — não sobrevive ao worktree. + try { + unlinkSync(lockPath) + } catch { + // não havia lock + } +} + const cmdKill = async (force) => { const entries = parseWorktreeList(git(['worktree', 'list', '--porcelain'])) const mainRoot = entries[0]?.path @@ -863,6 +928,9 @@ const cmdKill = async (force) => { // Read the generated DB names BEFORE the directory is removed. const databaseNames = worktreeDatabaseNamesOf(top) + // OPS110: encerra a sessão persistente do run antes de destruir o worktree. + stopAgentSession(branch) + git(['-C', mainRoot, 'worktree', 'remove', '--force', top]) if (branch) git(['-C', mainRoot, 'branch', '-D', branch]) @@ -893,17 +961,17 @@ if (!subcommand) { console.log(' opencode command, ou a função `worktree()` de .agents/shell/worktree.sh);') console.log(' no terminal (TEQO_WORKTREE_TERMINAL=1) imprime também a diretiva') console.log( - ' `launch opencode --model --auto --prompt "/work-issue --issue "` (OPS26+OPS33+OPS93+OPS95:', + ' `launch node scripts/agent-session.mjs start --purpose=next --dir= --model --issue=` (OPS26+OPS33+OPS93+OPS95+OPS110:', ) console.log( - ` abre o TUI com ${OPENCODE_PRESET_MODEL} (sem flag) ou com o mapa ${[ + ` abre a sessão com ${OPENCODE_PRESET_MODEL} (sem flag) ou com o mapa ${[ ...WORKTREE_MODEL_FLAGS, ] .map((flag) => `--${flag}=${WORKTREE_MODEL_MAP[flag]}`) - .join(' ')} + auto + a Issue claimada já`, + .join(' ')} + a Issue claimada auto-submetida pelo driver destacado;`, ) console.log( - ' informada); --stay suprime cd e launch (o claim ainda acontece); --no-migrate pula migrations e o', + ' fechar o terminal NÃO encerra o run — `pnpm agent:session attach|stop`); --stay suprime cd e launch (o claim ainda acontece); --no-migrate pula migrations e o', ) console.log( ' seed mínimo (db:seed:minimal) nos bancos novos (OPS28: paridade com a CI); at-most-one de --cheap/--pro/--zen/--go/--alibaba/--glm/--free (múltiplas → erro)', @@ -920,7 +988,7 @@ if (!subcommand) { ' minúsculo plans/… nunca colide com o branch - de `next`; no terminal,', ) console.log( - ' mesma diretiva `launch` com --prompt /plan-issue enviado (abre no fluxo de planejamento, sem digitação) e --model quando a flag está presente', + ' mesma diretiva `launch` — o driver destacado auto-submete /plan-issue (abre no fluxo de planejamento, sem digitação) e --model quando a flag está presente', ) console.log( `\n new [bag] [--stay] [--no-migrate] [--cheap|--pro|--zen|--go|--alibaba|--glm|--free]`, @@ -930,7 +998,7 @@ if (!subcommand) { console.log(' próximo work/ sequencial livre; o prefixo minúsculo work/… nunca colide com') console.log(' o branch - de `next` nem com plans/plan-issue-… de `plan`; no') console.log( - ' terminal, mesma diretiva `launch` porém sem --prompt (apenas conversar) e --model quando a flag está presente', + ' terminal, mesma diretiva `launch` — sessão sem skill (apenas conversar) e --model quando a flag está presente', ) console.log( `\n fix [bag] [--stay] [--no-migrate] [--headless --directive ] [--cheap|--pro|--zen|--go|--alibaba|--glm|--free]`, @@ -940,7 +1008,7 @@ if (!subcommand) { console.log(' nome já existir); sem bag, o próximo fix/ sequencial livre; o prefixo') console.log(' minúsculo fix/… nunca colide com `next`, `plan` nem `new`; não claima nem cria') console.log( - ' Issues — o registro do bug é o post-mortem da skill; no terminal, a diretiva `launch` envia --prompt "/bug-fix " (a descrição chega com a skill) e --model quando a flag está presente', + ' Issues — o registro do bug é o post-mortem da skill; no terminal, a diretiva `launch` carrega --argument="" (o driver destacado auto-submete /bug-fix , a descrição chega com a skill) e --model quando a flag está presente', ) console.log( ' OPS106: --headless --directive provisiona o mesmo worktree e grava a diretiva', @@ -950,7 +1018,9 @@ if (!subcommand) { ) console.log(' caminho do wrapper auto-unblock no homeserver.') console.log(' kill [--force] destrói o worktree em que você está (recusa sujo sem --force),') - console.log(' remove os bancos gerados do worktree (best-effort) e imprime') + console.log(' ENCERRA a sessão persistente do run (OPS110: abort + fim do') + console.log(' driver; estado/lock removidos) e remove os bancos gerados do') + console.log(' worktree (best-effort); imprime') console.log(' `cd
` no fim — o shell sempre volta ao worktree principal') process.exit(1) } diff --git a/tests/unit/agentSession.unit.spec.ts b/tests/unit/agentSession.unit.spec.ts new file mode 100644 index 00000000..42412112 --- /dev/null +++ b/tests/unit/agentSession.unit.spec.ts @@ -0,0 +1,490 @@ +// @vitest-environment node + +import { describe, expect, it } from 'vitest' + +import { + attachArgs, + codeFromBranch, + DEFAULT_AGENT_SERVER_HOST, + DEFAULT_AGENT_SERVER_PORT, + deriveSessionStatus, + driverArgs, + driverLogPath, + formatSessionList, + isLoopbackHost, + parseServerState, + parseSessionState, + purposeInvocation, + resolveServerConfig, + resolveSessionRef, + serializeServerState, + serializeSessionState, + serverArgs, + serverStatePath, + SESSION_COMMAND_BY_PURPOSE, + sessionDirFromEnv, + sessionListPayload, + sessionSlug, + sessionStatePath, + startLockPath, + STATE_VERSION, + validateServerBind, +} from '../../scripts/lib/agent-session.mjs' + +const state = (over: Record = {}) => ({ + version: STATE_VERSION, + code: 'OPS110', + issue: 1019, + purpose: 'next', + branch: 'OPS110-acompanhar-runs', + dir: '/home/fsolla/.cursor/worktrees/teqo/OPS110-acompanhar-runs', + sessionID: 'ses_f5b6ec763ffeTquA6nLR2XVxz3', + url: `http://${DEFAULT_AGENT_SERVER_HOST}:${DEFAULT_AGENT_SERVER_PORT}`, + model: 'deepseek/deepseek-flash', + driverPid: 4242, + logPath: '/home/fsolla/.local/state/teqo/agent-sessions/ops110-acompanhar-runs.log', + startedAt: '2026-09-15T10:00:00.000Z', + stoppedAt: null, + ...over, +}) + +describe('resolveServerConfig (OPS110 — loopback por padrão, env/flag overridáveis)', () => { + it('defaults to loopback:4199', () => { + expect(resolveServerConfig({ env: {} })).toEqual({ + hostname: '127.0.0.1', + port: 4199, + url: 'http://127.0.0.1:4199', + }) + expect(DEFAULT_AGENT_SERVER_HOST).toBe('127.0.0.1') + expect(DEFAULT_AGENT_SERVER_PORT).toBe(4199) + }) + + it('reads TEQO_AGENT_SERVER_HOST/PORT from the env', () => { + expect( + resolveServerConfig({ + env: { TEQO_AGENT_SERVER_HOST: '100.119.220.31', TEQO_AGENT_SERVER_PORT: '4300' }, + }), + ).toEqual({ hostname: '100.119.220.31', port: 4300, url: 'http://100.119.220.31:4300' }) + }) + + it('explicit flags win over the env (serve --hostname/--port)', () => { + expect( + resolveServerConfig({ + hostname: '127.0.0.1', + port: 5000, + env: { TEQO_AGENT_SERVER_PORT: '4300' }, + }), + ).toEqual({ hostname: '127.0.0.1', port: 5000, url: 'http://127.0.0.1:5000' }) + }) + + it('rejects a non-numeric/out-of-range port — fail high, never guess', () => { + expect(() => resolveServerConfig({ env: { TEQO_AGENT_SERVER_PORT: 'abc' } })).toThrow(/porta/) + expect(() => resolveServerConfig({ env: { TEQO_AGENT_SERVER_PORT: '70000' } })).toThrow(/porta/) + }) +}) + +describe('validateServerBind + isLoopbackHost (aceite: nada além do loopback sem credencial)', () => { + it('recognizes loopback spellings only', () => { + for (const host of ['127.0.0.1', '127.0.0.5', 'localhost', '::1', '[::1]', ' LOCALHOST ']) { + expect(isLoopbackHost(host)).toBe(true) + } + for (const host of [ + '0.0.0.0', + '100.119.220.31', + '192.168.1.10', + 'jorgesolla1313.com.br', + '127.0.0.1.evil', + '127.0.0.1.example.com', + ]) { + expect(isLoopbackHost(host)).toBe(false) + } + }) + + it('loopback never requires a password', () => { + expect(validateServerBind({ hostname: '127.0.0.1', password: '' })).toBe(true) + }) + + it('non-loopback without OPENCODE_SERVER_PASSWORD fails closed', () => { + expect(() => validateServerBind({ hostname: '100.119.220.31', password: '' })).toThrow( + /OPENCODE_SERVER_PASSWORD/, + ) + expect(() => validateServerBind({ hostname: '0.0.0.0', password: undefined })).toThrow( + /credencial|OPENCODE_SERVER_PASSWORD/, + ) + }) + + it('non-loopback with a password is allowed', () => { + expect(validateServerBind({ hostname: '100.119.220.31', password: 'x' })).toBe(true) + }) +}) + +describe('sessionDirFromEnv + caminhos de estado', () => { + it('TEQO_AGENT_SESSION_DIR wins; XDG_STATE_HOME; HOME fallback', () => { + expect(sessionDirFromEnv({ TEQO_AGENT_SESSION_DIR: '/tmp/runs', HOME: '/home/x' })).toBe( + '/tmp/runs', + ) + expect(sessionDirFromEnv({ XDG_STATE_HOME: '/xdg', HOME: '/home/x' })).toBe( + '/xdg/teqo/agent-sessions', + ) + expect(sessionDirFromEnv({ HOME: '/home/x' })).toBe('/home/x/.local/state/teqo/agent-sessions') + }) + + it('slugs the branch (paths with / become one filename) and derives every path', () => { + expect(sessionSlug('OPS110-acompanhar-runs')).toBe('ops110-acompanhar-runs') + expect(sessionSlug('plans/plan-issue-agenda eleitoral')).toBe( + 'plans-plan-issue-agenda-eleitoral', + ) + const sessionDir = '/state' + const branch = 'plans/plan-issue-agenda' + expect(sessionStatePath({ sessionDir, branch })).toBe('/state/plans-plan-issue-agenda.json') + expect(startLockPath({ sessionDir, branch })).toBe('/state/plans-plan-issue-agenda.lock') + expect(driverLogPath({ sessionDir, branch })).toBe('/state/plans-plan-issue-agenda.log') + expect(serverStatePath({ sessionDir })).toBe('/state/server.json') + }) +}) + +describe('codeFromBranch', () => { + it('reads the uppercase code prefix of a claim branch', () => { + expect(codeFromBranch('OPS110-acompanhar-runs')).toBe('OPS110') + expect(codeFromBranch('C15-fullcalendar')).toBe('C15') + }) + + it('returns null for namespace branches (plan/new/fix have no claim code)', () => { + expect(codeFromBranch('plans/plan-issue-1')).toBeNull() + expect(codeFromBranch('work/ideia')).toBeNull() + expect(codeFromBranch('fix/abc')).toBeNull() + }) +}) + +describe('estado da sessão (contrato OPS109)', () => { + it('serializes and parses the documented fields (roundtrip)', () => { + const parsed = parseSessionState(serializeSessionState(state())) + expect(parsed).toEqual(state()) + expect(parsed.sessionID).toMatch(/^ses/) + expect(parsed.version).toBe(STATE_VERSION) + }) + + it('fails high on a malformed state (never silently accepts)', () => { + expect(() => parseSessionState('not json')).toThrow(/inválido/) + expect(() => parseSessionState(JSON.stringify({ branch: 'x' }))).toThrow(/inválido/) + expect(() => parseSessionState(JSON.stringify({ ...state(), sessionID: 'nope' }))).toThrow( + /inválido/, + ) + }) + + it('serializes/parses the server state', () => { + const server = { + version: STATE_VERSION, + url: 'http://127.0.0.1:4199', + hostname: '127.0.0.1', + port: 4199, + pid: 777, + startedAt: '2026-09-15T10:00:00.000Z', + logPath: '/state/server.log', + } + expect(parseServerState(serializeServerState(server))).toEqual(server) + expect(() => parseServerState('{}')).toThrow(/inválido/) + }) +}) + +describe('purposeInvocation (mapa purpose→comando movido do worktree)', () => { + it('pins the skill command by purpose', () => { + expect(SESSION_COMMAND_BY_PURPOSE).toEqual({ + next: 'work-issue', + plan: 'plan-issue', + new: null, + fix: 'bug-fix', + }) + }) + + it('next carries the claimed issue as --issue ', () => { + expect(purposeInvocation({ purpose: 'next', issueNumber: 1019 })).toEqual({ + command: 'work-issue', + arguments: '--issue 1019', + }) + }) + + it('next without a valid issue starts no driver — nothing to auto-submit', () => { + expect(purposeInvocation({ purpose: 'next' })).toBeNull() + expect(purposeInvocation({ purpose: 'next', issueNumber: 'x' })).toBeNull() + expect(purposeInvocation({ purpose: 'next', issueNumber: 0 })).toBeNull() + }) + + it('plan sends the bare /plan-issue command', () => { + expect(purposeInvocation({ purpose: 'plan' })).toEqual({ command: 'plan-issue', arguments: '' }) + expect(purposeInvocation({ purpose: 'plan', issueNumber: 7 })).toEqual({ + command: 'plan-issue', + arguments: '', + }) + }) + + it('new has no command at all (apenas conversar)', () => { + expect(purposeInvocation({ purpose: 'new' })).toBeNull() + }) + + it('fix sends the sanitized bag (xargs/argv-safe); empty bag sends the bare command', () => { + expect(purposeInvocation({ purpose: 'fix', argument: 'a"b\\c bug' })).toEqual({ + command: 'bug-fix', + arguments: 'abc bug', + }) + expect(purposeInvocation({ purpose: 'fix', argument: ' "" ' })).toEqual({ + command: 'bug-fix', + arguments: '', + }) + }) + + it('unknown purpose degrades to no command (fail-safe direction)', () => { + expect(purposeInvocation({ purpose: 'bogus' })).toBeNull() + expect(purposeInvocation({})).toBeNull() + }) +}) + +describe('driverArgs + attachArgs (o argv verificado ao vivo)', () => { + it('drives the run through the server with --auto and --command + `--` separator', () => { + expect( + driverArgs({ + url: 'http://127.0.0.1:4199', + sessionID: 'ses_abc', + dir: '/work/OPS110', + model: 'deepseek/deepseek-flash', + invocation: { command: 'work-issue', arguments: '--issue 1019' }, + }), + ).toEqual([ + 'run', + '--attach', + 'http://127.0.0.1:4199', + '-s', + 'ses_abc', + '--dir', + '/work/OPS110', + '--model', + 'deepseek/deepseek-flash', + '--auto', + '--command', + 'work-issue', + '--', + '--issue 1019', + ]) + }) + + it('omits the `--` separator when there are no arguments (plan)', () => { + expect( + driverArgs({ + url: 'http://127.0.0.1:4199', + sessionID: 'ses_abc', + dir: '/work/plan', + model: 'm', + invocation: { command: 'plan-issue', arguments: '' }, + }), + ).toEqual([ + 'run', + '--attach', + 'http://127.0.0.1:4199', + '-s', + 'ses_abc', + '--dir', + '/work/plan', + '--model', + 'm', + '--auto', + '--command', + 'plan-issue', + ]) + }) + + it('fails high on a missing url/session/model (never spawns half a run)', () => { + expect(() => + driverArgs({ + url: '', + sessionID: 'ses_abc', + dir: '/d', + model: 'm', + invocation: { command: 'work-issue', arguments: '' }, + }), + ).toThrow(/url/) + expect(() => + driverArgs({ + url: 'http://x', + sessionID: '', + dir: '/d', + model: 'm', + invocation: { command: 'work-issue', arguments: '' }, + }), + ).toThrow(/session/) + expect(() => + driverArgs({ + url: 'http://x', + sessionID: 'ses_abc', + dir: '/d', + model: '', + invocation: { command: 'work-issue', arguments: '' }, + }), + ).toThrow(/model/) + expect(() => + driverArgs({ + url: 'http://x', + sessionID: 'ses_abc', + dir: '/d', + model: 'm', + invocation: null, + }), + ).toThrow(/invocation|comando/i) + }) + + it('fails high on a missing attach url/session/dir — never spawns a half attach', () => { + expect(() => attachArgs({ url: '', sessionID: 'ses_a', dir: '/d' })).toThrow(/url/) + expect(() => attachArgs({ url: 'http://x', sessionID: '', dir: '/d' })).toThrow(/session/) + expect(() => attachArgs({ url: 'http://x', sessionID: 'ses_a', dir: '' })).toThrow(/dir/) + }) + + it('attach resumes exactly the same session/dir (never passes --auto/--model)', () => { + const argv = attachArgs({ url: 'http://127.0.0.1:4199', sessionID: 'ses_abc', dir: '/work/x' }) + expect(argv).toEqual(['attach', 'http://127.0.0.1:4199', '--dir', '/work/x', '-s', 'ses_abc']) + expect(argv).not.toContain('--auto') + expect(argv).not.toContain('--model') + }) + + it('serverArgs is loopback-flagged and never enables --mdns', () => { + expect(serverArgs({ port: 4199, hostname: '127.0.0.1' })).toEqual([ + 'serve', + '--port', + '4199', + '--hostname', + '127.0.0.1', + ]) + expect(serverArgs({ port: 4199, hostname: '127.0.0.1' })).not.toContain('--mdns') + }) +}) + +describe('resolveSessionRef (--session > --branch > --issue > branch do cwd)', () => { + const next = state() + const plan = state({ + code: null, + issue: null, + branch: 'plans/plan-issue-agenda', + sessionID: 'ses_plan', + dir: '/work/plans/plan-issue-agenda', + }) + + it('resolves by session id first', () => { + expect( + resolveSessionRef({ states: [next, plan], session: 'ses_plan', branch: next.branch }), + ).toBe(plan) + }) + + it('resolves by branch, then by issue, then by cwd branch', () => { + expect(resolveSessionRef({ states: [next, plan], branch: plan.branch })).toBe(plan) + expect(resolveSessionRef({ states: [next, plan], issue: 1019 })).toBe(next) + expect(resolveSessionRef({ states: [next, plan], cwdBranch: next.branch })).toBe(next) + }) + + it('returns null when nothing matches', () => { + expect(resolveSessionRef({ states: [next, plan], session: 'ses_nope' })).toBeNull() + expect(resolveSessionRef({ states: [], cwdBranch: 'x' })).toBeNull() + expect(resolveSessionRef({ states: [next, plan] })).toBeNull() + }) +}) + +describe('deriveSessionStatus + list (contrato OPS109)', () => { + it('stopped > unknown (server down) > working (busy or driver alive) > idle', () => { + const live = state() + expect( + deriveSessionStatus({ + state: live, + serverHealthy: true, + sessionBusy: true, + driverAlive: false, + }), + ).toBe('working') + expect( + deriveSessionStatus({ + state: live, + serverHealthy: true, + sessionBusy: false, + driverAlive: true, + }), + ).toBe('working') + expect( + deriveSessionStatus({ + state: live, + serverHealthy: true, + sessionBusy: false, + driverAlive: false, + }), + ).toBe('idle') + expect( + deriveSessionStatus({ + state: live, + serverHealthy: false, + sessionBusy: false, + driverAlive: false, + }), + ).toBe('unknown') + const stopped = state({ stoppedAt: '2026-09-15T11:00:00.000Z' }) + expect( + deriveSessionStatus({ + state: stopped, + serverHealthy: true, + sessionBusy: true, + driverAlive: true, + }), + ).toBe('stopped') + }) + + it('list payload carries the versioned contract with the derived status', () => { + const payload = sessionListPayload({ + states: [state()], + statuses: { ses_f5b6ec763ffeTquA6nLR2XVxz3: 'working' }, + server: { url: 'http://127.0.0.1:4199', healthy: true, version: '1.18.31' }, + }) + expect(payload.version).toBe(STATE_VERSION) + expect(payload.server).toEqual({ + url: 'http://127.0.0.1:4199', + healthy: true, + version: '1.18.31', + }) + expect(payload.sessions).toHaveLength(1) + expect(payload.sessions[0]).toMatchObject({ + sessionID: 'ses_f5b6ec763ffeTquA6nLR2XVxz3', + issue: 1019, + status: 'working', + }) + }) + + it('list payload marks a session without a known status as unknown (never lies)', () => { + const payload = sessionListPayload({ + states: [state()], + server: { url: 'http://127.0.0.1:4199', healthy: false, version: null }, + }) + expect(payload.sessions[0].status).toBe('unknown') + }) + + it('human list prints one line per run with status/session/branch/dir', () => { + const lines = formatSessionList([ + { + status: 'working', + code: 'OPS110', + issue: 1019, + branch: 'OPS110-acompanhar-runs', + sessionID: 'ses_abc', + dir: '/work/OPS110', + }, + { + status: 'idle', + code: null, + issue: null, + branch: 'plans/plan-issue-1', + sessionID: 'ses_def', + dir: '/work/plans/plan-issue-1', + }, + ]) + expect(lines).toHaveLength(2) + expect(lines[0]).toContain('working') + expect(lines[0]).toContain('OPS110') + expect(lines[0]).toContain('#1019') + expect(lines[0]).toContain('ses_abc') + expect(lines[0]).toContain('/work/OPS110') + expect(lines[1]).toContain('plans/plan-issue-1') + expect(lines[1]).not.toContain('#null') + }) +}) diff --git a/tests/unit/worktree.unit.spec.ts b/tests/unit/worktree.unit.spec.ts index 6c8ad0da..bc54c623 100644 --- a/tests/unit/worktree.unit.spec.ts +++ b/tests/unit/worktree.unit.spec.ts @@ -22,7 +22,6 @@ import { issueCodeAndSubject, OPENCODE_HEADLESS_COMMAND, OPENCODE_PRESET_MODEL, - OPENCODE_SKILL_COMMAND_BY_PURPOSE, opencodeHeadlessArgs, opencodeLaunchDirective, PLAN_BRANCH_PREFIX, @@ -150,7 +149,7 @@ describe('planBranchName (per-invocation planning worktrees)', () => { }) }) -describe('opencodeLaunchDirective (terminal-only opencode launch, OPS26 + OPS33 + OPS93 + OPS95)', () => { +describe('opencodeLaunchDirective (terminal-only agent-session launch, OPS26 + OPS33 + OPS93 + OPS95 + OPS110)', () => { const dir = '/home/fsolla/.cursor/worktrees/teqo/OPS26-foo' it('returns null outside the terminal — the /worktree command never launches a TUI', () => { @@ -161,42 +160,55 @@ describe('opencodeLaunchDirective (terminal-only opencode launch, OPS26 + OPS33 ).toBeNull() }) - it('next launches with the preset model, --auto and the /work-issue command sent (value always quoted)', () => { + it('next delegates to scripts/agent-session.mjs start with the preset model (no local TUI)', () => { expect(opencodeLaunchDirective({ dir, purpose: 'next', terminal: true })).toBe( - `launch opencode ${dir} --model ${presetInEffect()} --auto --prompt "/work-issue"`, + `launch node scripts/agent-session.mjs start --purpose=next --dir=${dir} --model=${presetInEffect()}`, ) }) - it('next with an issueNumber sends /work-issue --issue with the value quoted (spaces)', () => { + it('uses the explicit sessionScript (absolute) — worktrees sem o arquivo novo ainda lançam', () => { + expect( + opencodeLaunchDirective({ + dir, + purpose: 'plan', + terminal: true, + sessionScript: '/repo/scripts/agent-session.mjs', + }), + ).toBe( + `launch node /repo/scripts/agent-session.mjs start --purpose=plan --dir=${dir} --model=${presetInEffect()}`, + ) + }) + + it('next with an issueNumber carries --issue= (the claimed issue)', () => { expect( opencodeLaunchDirective({ dir, purpose: 'next', terminal: true, issueNumber: 595 }), ).toBe( - `launch opencode ${dir} --model ${presetInEffect()} --auto --prompt "/work-issue --issue 595"`, + `launch node scripts/agent-session.mjs start --purpose=next --dir=${dir} --model=${presetInEffect()} --issue=595`, ) }) - it('plan launches with the same presets and /plan-issue sent (OPS31, value quoted)', () => { + it('plan launches through the same session CLI (the skill is auto-submitted by the driver)', () => { expect(opencodeLaunchDirective({ dir, purpose: 'plan', terminal: true })).toBe( - `launch opencode ${dir} --model ${presetInEffect()} --auto --prompt "/plan-issue"`, + `launch node scripts/agent-session.mjs start --purpose=plan --dir=${dir} --model=${presetInEffect()}`, ) }) - it('new launches prompt-less too — "apenas conversar", no skill sent', () => { + it('new launches session-only — "apenas conversar", no driver/skill', () => { expect(opencodeLaunchDirective({ dir, purpose: 'new', terminal: true })).toBe( - `launch opencode ${dir} --model ${presetInEffect()} --auto`, + `launch node scripts/agent-session.mjs start --purpose=new --dir=${dir} --model=${presetInEffect()}`, ) }) it('plan/new ignore the issueNumber — only next carries the claimed issue', () => { expect(opencodeLaunchDirective({ dir, purpose: 'plan', terminal: true, issueNumber: 7 })).toBe( - `launch opencode ${dir} --model ${presetInEffect()} --auto --prompt "/plan-issue"`, + `launch node scripts/agent-session.mjs start --purpose=plan --dir=${dir} --model=${presetInEffect()}`, ) expect(opencodeLaunchDirective({ dir, purpose: 'new', terminal: true, issueNumber: 7 })).toBe( - `launch opencode ${dir} --model ${presetInEffect()} --auto`, + `launch node scripts/agent-session.mjs start --purpose=new --dir=${dir} --model=${presetInEffect()}`, ) }) - it('fix launches with /bug-fix sent and the bag as the argument (quoted value)', () => { + it('fix carries the bag as --argument (quoted value, xargs-safe)', () => { expect( opencodeLaunchDirective({ dir, @@ -205,22 +217,24 @@ describe('opencodeLaunchDirective (terminal-only opencode launch, OPS26 + OPS33 argument: '500 no autosave de estimativas', }), ).toBe( - `launch opencode ${dir} --model ${presetInEffect()} --auto --prompt "/bug-fix 500 no autosave de estimativas"`, + `launch node scripts/agent-session.mjs start --purpose=fix --dir=${dir} --model=${presetInEffect()} --argument="500 no autosave de estimativas"`, ) }) - it('fix without a bag sends the bare /bug-fix', () => { + it('fix without a bag omits --argument', () => { expect(opencodeLaunchDirective({ dir, purpose: 'fix', terminal: true })).toBe( - `launch opencode ${dir} --model ${presetInEffect()} --auto --prompt "/bug-fix"`, + `launch node scripts/agent-session.mjs start --purpose=fix --dir=${dir} --model=${presetInEffect()}`, ) }) it('fix strips quotes/backslashes from the bag (xargs-safe) and drops it when it empties', () => { expect( - opencodeLaunchDirective({ dir, purpose: 'fix', terminal: true, argument: 'a"b\\c' }), - ).toBe(`launch opencode ${dir} --model ${presetInEffect()} --auto --prompt "/bug-fix abc"`) + opencodeLaunchDirective({ dir, purpose: 'fix', terminal: true, argument: 'a"b\\c bug' }), + ).toBe( + `launch node scripts/agent-session.mjs start --purpose=fix --dir=${dir} --model=${presetInEffect()} --argument="abc bug"`, + ) expect(opencodeLaunchDirective({ dir, purpose: 'fix', terminal: true, argument: ' "" ' })).toBe( - `launch opencode ${dir} --model ${presetInEffect()} --auto --prompt "/bug-fix"`, + `launch node scripts/agent-session.mjs start --purpose=fix --dir=${dir} --model=${presetInEffect()}`, ) }) @@ -233,32 +247,32 @@ describe('opencodeLaunchDirective (terminal-only opencode launch, OPS26 + OPS33 issueNumber: 7, argument: 'bug x', }), - ).toBe(`launch opencode ${dir} --model ${presetInEffect()} --auto --prompt "/bug-fix bug x"`) + ).toBe( + `launch node scripts/agent-session.mjs start --purpose=fix --dir=${dir} --model=${presetInEffect()} --argument="bug x"`, + ) }) it('the argument belongs to fix alone — plan/new ignore it', () => { expect( opencodeLaunchDirective({ dir, purpose: 'plan', terminal: true, argument: 'bag x' }), - ).toBe(`launch opencode ${dir} --model ${presetInEffect()} --auto --prompt "/plan-issue"`) + ).toBe( + `launch node scripts/agent-session.mjs start --purpose=plan --dir=${dir} --model=${presetInEffect()}`, + ) expect( opencodeLaunchDirective({ dir, purpose: 'new', terminal: true, argument: 'bag x' }), - ).toBe(`launch opencode ${dir} --model ${presetInEffect()} --auto`) + ).toBe( + `launch node scripts/agent-session.mjs start --purpose=new --dir=${dir} --model=${presetInEffect()}`, + ) }) it('pins the preset constants — fallback comum deepseek-flash, override via OPENCODE_WORKTREE_MODEL', () => { expect(OPENCODE_PRESET_MODEL).toBe(presetInEffect()) expect(WORKTREE_TERMINAL_ENV).toBe('TEQO_WORKTREE_TERMINAL') - expect(OPENCODE_SKILL_COMMAND_BY_PURPOSE).toEqual({ - next: '/work-issue', - plan: '/plan-issue', - new: null, - fix: '/bug-fix', - }) }) - it('an unknown purpose degrades to a prompt-less launch (fail-safe direction)', () => { + it('an unknown purpose still delegates — the session CLI decides there is no command (fail-safe)', () => { expect(opencodeLaunchDirective({ dir, purpose: 'bogus', terminal: true })).toBe( - `launch opencode ${dir} --model ${presetInEffect()} --auto`, + `launch node scripts/agent-session.mjs start --purpose=bogus --dir=${dir} --model=${presetInEffect()}`, ) }) @@ -274,7 +288,7 @@ describe('opencodeLaunchDirective (terminal-only opencode launch, OPS26 + OPS33 it('uses the explicit model when provided (OPS93 map, OPS95 values)', () => { for (const [, model] of Object.entries(WORKTREE_MODEL_MAP)) { expect(opencodeLaunchDirective({ dir, purpose: 'next', terminal: true, model })).toBe( - `launch opencode ${dir} --model ${model} --auto --prompt "/work-issue"`, + `launch node scripts/agent-session.mjs start --purpose=next --dir=${dir} --model=${model}`, ) } }) @@ -378,13 +392,13 @@ describe('resolveWorktreeModel + WORKTREE_MODEL_MAP (OPS93 menu, OPS95 values, O expect(resolved).toBe(model) expect( opencodeLaunchDirective({ dir, purpose: 'next', terminal: true, model: resolved }), - ).toContain(`--model ${model} --auto`) + ).toContain(`--model=${model}`) expect( opencodeLaunchDirective({ dir, purpose: 'plan', terminal: true, model: resolved }), - ).toContain(`--model ${model} --auto`) + ).toContain(`--model=${model}`) expect( opencodeLaunchDirective({ dir, purpose: 'new', terminal: true, model: resolved }), - ).toContain(`--model ${model} --auto`) + ).toContain(`--model=${model}`) } }) })