-`**: `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