SLOPSHOPPER

ork-mensageiro

Mensageiro de Sessões do Orkastery no Claude Code: leva a resposta que o dono deu pelo diálogo nativo à sessão de fase claude-bg que parou esperando por ele…

newpanerowscommandtoasttimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ork-mensageiro
│ ┃ mensageiro ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ Mensageiro de Sessões[ Atualizar ] │ ork-mensageiro │ │ ┃ Leva a resposta que você deu pelo diálogo à… ⏺ Read(src/auth.ts) │ mensageiro: a ponte ork-ponte não │ │ ┃ Respostas desta sessão ⎿ Read 6 lines │ carregou; o Mensageiro de Sessões não leva │ │ ┃ nenhuma resposta a caminho agora ⏺ Update(src/auth.ts) │ respostas nesta sessão, e mensagem de │ │ ┃ A resposta chega como mensagem de outra ses… ⎿ Added 2 lines, re╰────────────────────────────────────────────╯ │ ┃ a caixa de saída ainda não foi lida ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · mensageiro
Mensageiro de Sessões[ Atualizar ] Leva a resposta que você deu pelo diálogo à sessão de fase … Respostas desta sessão nenhuma resposta a caminho agora A resposta chega como mensagem de outra sessão: informa a s… a caixa de saída ainda não foi lida
README

Orkastery

English · Português

npm MIT license

A software factory of AI agents that proves its work: GOAL, PLAN, GO, CHECK, SHIP and MASTER.

In one sentence: ork conducts AI coding agents (Claude Code and Codex) through parallel six-phase threads, checks every claim against the repository before it counts, and only calls you when the decision is really yours.

  • Status: in daily use · on npm as @orkastery/cli, with changes per version · reviewed on 2026-09-27
  • Proof: GitHub CI on every PR: 2,733 core tests, 24 canaries and 20 skills with 199 assertions, zero failures (CI of the main at PR #74, run 37102089623, 2026-10-03)
  • Product and roadmap: docs/produto/ and docs/roadmap/, checked against the code by ork docs verificar
  • Language: this page is the canonical entry point, mirrored in Portuguese. The pages under docs/ are in Brazilian Portuguese, and the CLI reference is also in English. The CLI prints Brazilian Portuguese too, except ork doctor, ork init, ork onboarding, ork experiencia show, ork thread (except thread new --from-finding), ork phase list, ork phase run, ork verify, ork ship, ork pulse, ork master, ork retry plan, ork retry run, ork roadmap (status and reservations), ork worktree, ork claims, ork decisao placar, ork accounts, ork network (except network roadmap), ork docs, ork adapter and the help, which print English when owner.language or your locale asks for en; so do the orkastery.yaml comments, the AGENTS.md and CLAUDE.md blocks, the ork docs init templates and the skills, checklists, commands and subagents that ork adapter install copies; in English, read the quickstart on orkastery.com and the glossary of what the CLI prints
npm install -g @orkastery/cli
ork demo          # 30 seconds: a false claim rejected, the fixed one accepted; no account, no model
ork doctor        # what holds on this machine right now; exits != 0 when blocked
ork init          # writes the orkastery.yaml; run it at the root of a git repository with at least one commit
ork modos         # the four conduction modes
ork docs init     # product docs and roadmap in the standard, with lint

Your first thread

The smallest full cycle, in #Fast mode (a single GO phase), right after ork init. <thread> is the ID that ork thread new prints.

git add orkastery.yaml AGENTS.md && git commit -m "ork init"
ork thread new "add an exclamation mark to greet" --modo fast
ork phase run <thread> GO --prompt "<the request>"   # the agent runtime works in the thread's worktree
ork verify <thread>                                  # re-runs the claims and the manifest checks on the real HEAD
ork ship <thread> --para main --autorizar-push <you> # #Fast never pushes without your authorization
ork master <thread> --aceitar-omissao                # closes the thread with the index derived from the ledger
  • If phase run stops with runtime.workspace-untrusted, run claude once in the worktree, accept the trust prompt, then ork retry run <thread>. ork pulse shows the same instruction.
  • A claim is what the agent states plus the command that judges it: ork claims add <thread> <file> --claim "<statement>" --verificar "<command>".

Why it exists

  • Coding agents got good. Conducting agents did not.
  • Three agents in the same repository edit the same files, skip the same steps and hand over a report saying it worked. The report is the part that is worth nothing.
  • Someone is missing between you and them: someone who holds the roadmap, isolates the loops, serializes the merge, re-runs the claim and only interrupts you when it matters.
  • ork takes that seat. It is a deterministic TypeScript CLI, with no server and no embedded LLM.
  • Decisions sized for a full day. Whoever conducts agents is also running dozens of other things. Every question ork brings you is short and organized, with alternatives and one recommendation, so you decide in seconds without rereading a wall of text. That lightness is a design goal, meant also for people with ADHD: it keeps you conducting instead of drowning.

What changes for builders

Without orkWith ork
The agent says the tests passork verify re-runs the command on the real HEAD, and CI re-runs it again on a clean runner
You ask for status several times a dayAn hourly summary says what is pending, urgent, blocking or critical
Every obvious decision becomes a questionThe obvious one comes decided and reported; you change it if you want
The runtime account runs out and the roadmap stopsThe same prompt continues on the next profile or on the fallback runtime
Two sessions step on the same fileOne worktree per thread, and leases with a queue
The docs lie about the codeork docs verificar fails the PR when a page drifts

How it works

Three layers, and business rules live only in the middle one:

flowchart TB
    subgraph L1["Layer 1 - where you conduct"]
        A["Claude Code, Codex, Hermes, OpenClaw<br/>adapters with no business rules"]
    end
    subgraph L2["Layer 2 - the ork core"]
        B["schedules, isolates, verifies, serializes, records<br/>deterministic TypeScript, no LLM"]
    end
    subgraph L3["Layer 3 - who writes the code"]
        C["claude-bg and codex runtimes<br/>their own accounts, profile rotation"]
    end
    A -->|"thread new, phase run, gate, ship"| B
    B -->|"assembled prompt, sha256 in the ledger"| C
    C -.->|"commits, diffs, command output"| B
    B -.->|"summary, question batch, delivery"| A

The thread: six phases no agent skips

flowchart LR
    G(["GOAL"]) --> P(["PLAN"]) --> GO(["GO"]) --> C(["CHECK"]) --> S(["SHIP"]) --> M(["MASTER"])
    C -.->|"failed: GO-FIX with a typed spec"| GO
PhaseWhat it deliversWhat ork requires to pass
GOALObjective, done criteria, claims with a commanda claim without a command is refused (claims.unverifiable)
PLANTasks, decisions, an executable verify per taska plan without an executable verify does not become GO
GOOne atomic commit per task, in the thread's worktreewriting outside the worktree is blocked by a lease
CHECKVerification against the baseline, plus independent CIa failed claim becomes claims.failed or verify.regression
SHIPSerialized merge with a proven pushonly counts when git ls-remote matches the local SHA
MASTERTyped postmortem and a conduction indexthe index comes from the ledger; nobody types it

Four modes, chosen by #TAG

You write the #TAG in the request itself. The mode changes where the cycle waits for you; verifying the delivery is the same in all four.

#TAGPausesCycleWhat it is for
#Classic3GOAL* / PLAN* / GO-CHECK* / SHIP-MASTERThe ork init default: delicate premises
#Maestro1GOAL-PLAN* / GO-CHECK-SHIP / MASTERA clear, quick solution
#Auto0GOAL-PLAN-GO-CHECK-SHIP-MASTERDocs, studies, configuration, audits
#Fast0GOA small, clear request that takes minutes; the push asks for authorization
  • * marks the block that pauses. Source of truth: ork modos.
  • #Look and #Ork were retired on 2026-09-24 (RM-043); what was recorded with them stays readable forever.
  • #Fast runs only GO, with a minimal proof: a focused test, a cheap claim or an absence declared in the ledger. It never authorizes a push on its own and never touches a public contract (RM-042).
The mode loosens the PAUSE. The mode NEVER loosens the VERIFICATION.

Truth, not reports

  • A claim is a contract: the statement, the file and the command that judges it, in claims.jsonl. There are even negative claims, checked by the command that would falsify them.
  • The baseline separates regression from debt: ork verify --baseline records the world before GO.
  • Independent CHECK: ork ci prepare exports the claims, and the GitHub ork-verify job re-runs them on the exact SHA.
  • Proven push: by git ls-remote, never by the exit code of git push.
  • Prompt sha256 and the effective trio in the ledger: the runtime, model and effort of each phase are facts, not statements.
  • 22 typed gate reasons, each with its own retry action: ork retry policy. cost.violation never gets an automatic retry.

Layered human attention

  • Hourly summary: how many items are pending, how many are urgent, how many block a thread, how many are critical, and "can I send them now?".
  • Batch: with your yes, up to 5 objective questions at a time, each with alternatives a–d and one recommended; then up to 2 open ones, one at a time.
  • The obvious decision comes made: ork decisao registrar keeps the why, how to change it and the cost of changing it now or later; you are informed.
  • An expired deadline waits or escalates, never approves. An answer only counts with the HMAC proof of the authenticated channel; the agent never answers for you.
  • Times in your time zone: owner.timezone, stated once per message.

Runtimes and accounts

  • Two runtimes: claude-bg (default) and codex, resolved by name in core/src/runtimes.ts; ork setup picks runtime, model and effort per block of each mode.
  • Accounts per profile: ork accounts add <id> --runtime R --dir D runs the CLI's own login; ork never reads, copies or migrates a credential.
  • Rotation: an exhausted quota or a lost login sends the same prompt to the next profile or to the block's fallback; a short rate limit waits in the queue.
  • Subscription only: a paid key in the factory's environment is a cost.violation. Details and responsibilities in SECURITY.md.

Context and memory

  • Token gate: decides between continuing in the session or opening another; without a measurement it does not rotate, and it says so.
  • Triaged handoff: CRITICAL goes inline, IMPORTANT becomes a pointer with the moment to fetch it, SUMMARIZABLE goes with provenance.
  • Optional OrkMind: with memory: orkmind, memory goes to the tenant's database; without it, it degrades to files with a typed reason.

Documentation as code

  • Two versioned standards: product documentation and product roadmap, v1.1.
  • Three readers per page: a person with ADHD (answer first, one idea per line), the parity checker, and AI agents (YAML frontmatter with stable IDs).
  • ork docs verificar: fails a source, symbol, contract or command that no longer exists, a merge commit outside main, and incoherent status.
  • ork docs sincronizar --escrever: writes only facts from the ledger and git (merge, phase) and regenerates tables and indexes.
  • In CI: the documentacao job runs markdownlint and ork docs verificar on every PR.

Install on your host

ork adapter list                     # the hosts and where each one installs
ork adapter install claude-code --dry-run
HostWhat gets installed
claude-codeA plugin with the catalog skills, phase subagents, the /orkastery:ork entry point and hooks
codexThe $ork entry point and the conduction catalog as project-local skills
hermesA routing skill, the HITL ingress plugin and thread-opening scripts
openclawAn extension with the ork_* tools, each one a CLI call
  • Zero business rules in the host: the #TAG becomes a mode because the host calls ork modos --do-pedido; the core validates it.
  • Installation with a receipt: sha256 per file; a divergence on both sides asks for a decision, file by file.

What exists today, and what does not

CapabilityStatus
Threads, six phases, four modes, ledger, worktrees, leases, boardworks
Claims, baseline, verify on the real HEAD, 22 typed reasons, GO-FIXworks
CI as an independent CHECK on the exact SHAworks; native protection of main depends on the GitHub plan (RM-012)
ork ship with a proven pushworks
Account rotation across profiles and runtimesworks; account state is still per project (RM-040)
Layered HITL, a–d batch, decisions made and reportedworks since 2026-09-24 (RM-041); automatic hourly summary since 2026-09-25 (RM-045)
Documentation as code with parity in CIworks (RM-044)
#Fast mode: one phase, minimal proof, no push on its ownworks since 2026-09-27 (RM-042)
Roadmap item reservations across machinesworks since 2026-09-27 (RM-047); thread state is still per machine
Stable local verify on a machine whose CPU the hypervisor stealsdoes not exist: on such a machine, the reliable proof is CI (RM-037)
A runtime_reported measurement of the context windowdoes not exist: claude-bg does not expose context usage, and the gate says unavailable
Deterministic code graphworks in the CLI (ork grafo: index, incremental update, queries and thread context, with each edge's provenance), for npm installs since 0.5.3; the MCP tools and the hint in the phase request stay behind the grafo.mcp flag, off by default (RM-031)

The full picture, item by item and with git evidence, is in the roadmap index.

Verified in this tree

MeasureValueHow to check
Core tests2,733, zero failing (CI on 2026-10-03)npm --prefix core run test:ci
Behavior canaries24, all greenork eval --so-canarios
Skills corpus20 skills, 101 cases, 199 assertionsork eval --so-skills
Product docs and roadmapzero parity errorsork docs verificar
Runtime dependencies9, pinnedcore/package.json

On a virtual machine whose CPU the hypervisor steals (the steal in sar), the local verify can overrun the tests' time windows. That is why the merge gate is the independent CI, and the fix is planned in RM-037.

Documentation

The docs home is organized by what you want to do now. Every page under docs/ is in Brazilian Portuguese; the CLI reference and the orchestration experience guide also have English versions, and the English quickstart lives on orkastery.com.

SectionStart with
Getting startedQuickstart in English, and the full quickstart in Portuguese: from zero to the first full cycle
GuidesModes, verification, human attention, memory, audits
ReferenceCLI (the source is ork --help) and contracts
ConceptsOverview and architecture
Product and roadmapWhat exists and what comes next, checked against the code

Reading the CLI in English

Many commands still print Brazilian Portuguese (ork doctor, ork init, ork onboarding, ork experiencia show, ork thread (except thread new --from-finding), ork phase list, ork phase run, ork verify, ork ship, ork pulse, ork master, ork retry plan, ork retry run, ork roadmap (status and reservations), ork worktree, ork claims, ork decisao placar, ork accounts, ork network (except network roadmap), ork docs, ork adapter and the help follow an English locale). These are the words a first run shows.

The CLI printsIt means
PRONTO / BLOQUEADOready / blocked: the ork doctor verdict
correcaothe fix for the line above it
Proximo passonext step
VERDADE SUSTENTADA / REPROVADOthe claims held / failed: the ork verify verdict
AGUARDANDO AUTORIZACAO HUMANAwaiting for your authorization
Precisa de humano agoraneeds a human now: the first line of ork pulse
Recomendação, opçãorecommendation, option
aceitas por omissaoaccepted by default, because nobody scored them
pendente, respondidapending, answered
Horários em <zone>times are shown in that time zone
  • Dates are day/month in Portuguese output: 03/10 06:30 is October 3. The commands that follow an English locale print 2026-10-03 06:30.
  • Commands and flags in Portuguese: modos (modes), --modo (mode; --mode also works), --para (target branch), --por (who records it), --autorizar-push (authorize the push), --conteudo (content), --verificar (the command that verifies), decisao registrar (record a decision), docs verificar (check the docs), experiencia show (conversation preferences).
  • owner.language: en-US sets the language of the conversation in your host (Claude Code, Codex), of the CLI commands that follow the locale and of what ork init, ork docs init and ork adapter install write for people to read (the orkastery.yaml comments, the AGENTS.md and CLAUDE.md blocks, the docs templates and the skills, checklists, commands and subagents the adapter installs); set it when machines with different locales conduct the same project, or those committed blocks switch language on each run; owner.language: pt-BR keeps them in Portuguese even with LANG=en_US.
TermMeaning
HITLhuman in the loop: a question only you can answer, with alternatives and one recommendation
ledgerthe append-only event log of a thread, in .orkastery/threads/<thread>/ledger.jsonl; ork phase list <thread> prints it
claima statement, the file it is about and the command that judges it
worktreethe separate git checkout of each thread, under .claude/worktrees/
leasea lock on a shared resource, such as the main tree during a merge, with a queue
pulsethe hourly summary of what waits for you: ork pulse
maestrothe conversational overview in a host (say orkastery maestro), and the onboarding stage about you
GOAL … MASTERthe six phases, in the thread table
#Classic, #Maestro, #Auto, #Fastthe conduction modes, in the modes table

Built with itself

  • Orkastery is built with Orkastery: every change starts in a thread, with claims that CI re-runs on the exact SHA before the merge.
  • Each thread's ledger stays on the machine that conducts it; what reaches this repository is the PR with the proof and the roadmap synced from git by ork docs sincronizar.
  • More than one machine works in parallel: before starting, each one reserves the roadmap item (ork roadmap pegar), and two never take the same one.

Ecosystem

ProjectWhat it is
orkasteryThis repository: the ork core, the adapters, the skills and the auditors
OrkMindThe memory layer: Postgres with pgvector, an ontology and tag search; optional
orkastery.comThe website and user docs
orkmind.comThe OrkMind site

Maestro in the conversation (code delivered, live activation pending)

  • Say orkastery maestro in a host with the adapter installed: you get an overview with sources, gaps, threads, sessions, HITL and next actions. In the terminal: ork maestro --json.
  • The query does not open work and does not grant a gate.
  • Top priority: HITL usability. Questions in short bullets, with a recommendation and clear options; the UUID stays internal.
  • Telegram is optional. There is no web control panel: Orkastery conducts in the conversation, and OrkMind keeps the knowledge.
  • Status: code package delivered on 2026-09-19 (PR #13). Proof of activation in a new session per host is still pending (RM-032).

Credits and license

No metronome ever turned anyone into Bernstein. Mastery is not tempo: it is knowing, after each performance, what you did, what it cost and how well it went, and being better at the next one. That is the phase we call MASTER, and it is why the project is called Orkastery.

Source 5 files
hooks/register.tsx 482 lines
1// O Mensageiro de Sessoes do Orkastery (RM-059, fatia F9; ficha M9 do plano dos mods). Na sessao do dono, leva a
2// resposta que o dono deu pelo dialogo nativo a sessao de fase `claude-bg` que parou esperando por ela: pega a
3// mensagem na caixa de saida do nucleo (`ork_session_outbox`, so na memoria do servidor desta sessao), envia por
4// `$.session.send` sem mexer no texto e anota o envio no nucleo. Na sessao de fase, so deixa entrar na conversa a
5// mensagem de outra sessao que o nucleo confirma, pela `ork_session_inbox`, como a resposta do dono reservada para
6// ela; o resto que chega de outra sessao e consumido antes do modelo. Le o que segue incerto pelo `$.ork.retrato`,
7// nunca o estado em `.orkastery/` por conta propria. Nunca responde, aprova ou abre pedido pelo dono. A logica fica
8// nos modulos puros ao lado; aqui so os ganchos e as chamadas ao `$`, sempre escritas como `x.noun.metodo(...)`, como
9// o validador exige.
10import type { EngineInterface, Register, SessionReceiveInput, SessionReceiveResult } from 'claude-code'
11import type { MensageiroDono, MensageiroFase, MensageiroMostrado, MensageiroRecebida } from '../types'
12import type { EntradaDaCaixa } from './caixa'
13import {
14  argsDaAnotacao, argsDoEnvio, atualizarEntregas, deveEnviar, DONO_VAZIO, entradaAnotada, FERRAMENTA_DA_CAIXA, incertasDoRetrato,
15  jsonDoMcp, lerCaixa, pedidosNoRetrato, resultadoDoEnvio, textoDaFaixa, threadDoLedger, umaLinha,
16} from './caixa'
17import { desenharPainel, linhasDaFase, linhasDoDono, mostradosVisiveis } from './painel'
18import type { Conferencia } from './recepcao'
19import {
20  argsDaConferencia, argsDaConfirmacao, classificarOrigem, comRecebida, confirmadasDaFase, ESPERAS_DA_CONFIRMACAO_MS, FASE_VAZIA,
21  decisaoNaFalha, FERRAMENTA_DA_CONFERENCIA, idsDaMarca, impressaoDoTexto, lerConferencia, lerConfirmacao, LIMITE_DA_MENSAGEM,
22  mensagemSemEnvelope, motivoDoConsumo, mudarRecebida, nomeDaOrigem, registroDaRecebida, temMarca, textoComRotulo, textoParaOModelo,
23} from './recepcao'
24
25const MOD = 'ork-mensageiro'
26const PAINEL = 'mensageiro'
27const TITULO = 'Mensageiro de Sessões'
28const DONO = { plugin: 'ork-mensageiro', key: 'dono' } as const
29const FASE = { plugin: 'ork-mensageiro', key: 'fase' } as const
30/** D9 da thread ork-rm059mensag: o tique do lado do dono, que tambem mantem o Mensageiro vivo na caixa de saida. */
31const PERIODO_DO_TIQUE_MS = 20000
32/** Os ledgers que mudam juntos entram numa leitura so. */
33const ESPERA_DO_LOTE_MS = 1000
34/** Teto de cada chamada ao nucleo. */
35const PRAZO_DO_NUCLEO_MS = 20000
36/** O servidor do nucleo pode conectar depois do inicio da sessao: o papel e conferido de novo nestas esperas. */
37const ESPERAS_DO_PAPEL_MS = [3000, 10000, 30000]
38const ESPERA_LONGA_DO_PAPEL_MS = 300000
39
40// O que o modulo guarda entre ganchos. O estado em `$.state` (o lado do dono e o da fase) e o que o desenho le; aqui
41// ficam a copia dele, gravada inteira por `$.state.set` sem versao (uma gravacao por mudanca), e o que nao se
42// desenha. Uma recarga do modulo zera tudo, e o `session.start` dispara de novo nela.
43let ativa = false
44let naFase = false
45let desistiu = false
46let ciclo: { cancel: () => void } | undefined
47let lote: { cancel: () => void } | undefined
48let lendo: Promise<void> | undefined
49let pendente = false
50let dono: MensageiroDono = DONO_VAZIO
51let fase: MensageiroFase = FASE_VAZIA
52/**
53 * O papel e a thread que a ponte deu. O papel `fase` sai do `ORK_DISPATCH_ID` do despacho e nunca muda na sessao, e
54 * a sessao que nao e de fase nunca vira: o receptor decide por isto, lido uma vez.
55 */
56let contextoVisto: { papel: string; thread: string | null } | null = null
57/**
58 * As mensagens que o nucleo confirmou e que ainda nao entraram na fila, com o texto que o modelo vai ler: o `.catch`
59 * do receptor deixa passar so elas, e no envelope do Mensageiro.
60 */
61const confirmadasEmVoo = new Map<string, string>()
62/** O ultimo texto publicado na faixa; null antes da primeira publicacao. */
63let ultimaFaixa: string | undefined | null = null
64/** O que o desenho pos na janela de rolagem e ainda nao foi ao registro da ponte (M5). */
65const vistosNoDesenho = new Map<string, MensageiroMostrado>()
66/** O que ja foi ao registro nesta sessao, por chave e texto. */
67const jaMostrado = new Set<string>()
68/** A falha de um trabalho em segundo plano que nem o painel conseguiu guardar. */
69let problemaSemAviso: string | null = null
70
71const mensagem = (erro: unknown) => umaLinha(erro, 200)
72
73/** A chamada com teto proprio: o relogio da sessao recusa a espera que passar dele. */
74function comPrazo<T>($: EngineInterface, chamada: Promise<T>, ms = PRAZO_DO_NUCLEO_MS): Promise<T> {
75  return new Promise<T>((resolver, recusar) => {
76    const prazo = $.clock.after(ms, () => recusar(new Error(`o núcleo passou de ${Math.round(ms / 1000)} s`)))
77    chamada.then(v => { prazo.cancel(); resolver(v) }, e => { prazo.cancel(); recusar(e) })
78  })
79}
80
81/**
82 * O instante do relogio da sessao para as linhas do estado; sem ele, o da ultima linha. O prazo da conferencia nao usa
83 * isto: sem relogio, a conferencia falha e a mensagem e consumida.
84 */
85async function instante($: EngineInterface): Promise<number> {
86  try {
87    return await $.clock.now()
88  } catch {
89    return fase.recebidas.at(-1)?.em ?? 0
90  }
91}
92
93async function gravarDono($: EngineInterface, novo: MensageiroDono): Promise<void> {
94  dono = novo
95  await $.state.set(DONO, novo)
96}
97
98async function gravarFase($: EngineInterface, nova: MensageiroFase): Promise<void> {
99  fase = nova
100  await $.state.set(FASE, nova)
101}
102
103/**
104 * Trabalho em segundo plano (o tique, o lote, a leitura pedida, o recibo da entrega): a falha vai ao painel; sem como
105 * gravar (a sessao acabou ou o estado recusou), ela fica guardada aqui e o proximo desenho a mostra. O `.catch` nao
106 * depende do `$`.
107 */
108function emSegundoPlano($: EngineInterface, trabalho: Promise<unknown>, oQue: string): void {
109  trabalho.catch(async erro => {
110    const texto = `${oQue} parou: ${mensagem(erro)}`
111    try {
112      if (naFase) await gravarFase($, { ...fase, erro: texto })
113      else await gravarDono($, { ...dono, erro: texto })
114    } catch (erroDoAviso) {
115      problemaSemAviso = `${texto} (o aviso também falhou: ${mensagem(erroDoAviso)})`
116    }
117  })
118}
119
120/**
121 * O papel e a thread pela ponte. `null` so quando o `$.ork` nao existe (a ponte nao carregou); a falha da propria
122 * ponte lanca. O que a ponte respondeu fica em `contextoVisto`.
123 */
124async function contextoDaSessao($: EngineInterface): Promise<{ papel: string; thread: string | null } | null> {
125  let lido: { papel: unknown; thread: unknown }
126  try {
127    lido = await comPrazo($, $.ork.contexto())
128  } catch (erro) {
129    if (erro instanceof TypeError) return null
130    throw erro
131  }
132  contextoVisto = { papel: String(lido.papel), thread: typeof lido.thread === 'string' ? lido.thread : null }
133  return contextoVisto
134}
135
136/** Liga o lado do dono: o `/mensageiro`, o tique e a primeira leitura da caixa. */
137async function ativarDono($: EngineInterface): Promise<void> {
138  if (ativa) return
139  ativa = true
140  try {
141    await $.command.register({ name: 'mensageiro', description: 'Abre o Mensageiro de Sessões: as respostas do dono a caminho das sessões de fase', immediate: true })
142  } catch (erro) {
143    // Sem o /mensageiro o painel nao abre: o lado do dono volta a dormente e tenta de novo na espera longa.
144    ativa = false
145    $.clock.after(ESPERA_LONGA_DO_PAPEL_MS, () => { emSegundoPlano($, conferirPapel($, ESPERAS_DO_PAPEL_MS.length), 'a conferência do papel') })
146    throw erro
147  }
148  dono = (await $.state.get(DONO)).value ?? DONO_VAZIO
149  ciclo?.cancel()
150  ciclo = $.clock.every(PERIODO_DO_TIQUE_MS, () => { emSegundoPlano($, levar($), 'o tique do Mensageiro') })
151  agendar($, 1)
152}
153
154/** Liga o lado da sessao de fase: o `/mensageiro` mostra o que chegou. O filtro do receptor nao depende disto. */
155async function ativarFase($: EngineInterface): Promise<void> {
156  if (naFase) return
157  naFase = true
158  desistiu = true
159  const guardada = (await $.state.get(FASE)).value
160  // O que chegou antes desta leitura ja esta na memoria e no estado: a copia guardada so volta quando nada chegou.
161  if (guardada && !fase.recebidas.length) fase = guardada
162  fase = { ...fase, thread: contextoVisto?.thread ?? fase.thread }
163  try {
164    await $.command.register({ name: 'mensageiro', description: 'Abre o Mensageiro de Sessões: o que chegou de outras sessões a esta sessão de fase', immediate: true })
165  } catch (erro) {
166    // O filtro segue sem o comando; o painel diz por que ele nao abre.
167    await gravarFase($, { ...fase, erro: `o /mensageiro não foi registrado: ${mensagem(erro)}` })
168  }
169}
170
171/** Confere o papel: o dono liga o lado do dono, a fase liga o da fase, e a sessao dormente e conferida de novo. */
172async function conferirPapel($: EngineInterface, tentativa: number): Promise<void> {
173  if (ativa || naFase || desistiu) return
174  let papel: string | null
175  try {
176    papel = (await contextoDaSessao($))?.papel ?? null
177  } catch {
178    // A ponte falhou: a sessao conta como dormente e o papel e conferido de novo depois.
179    papel = 'dormente'
180  }
181  if (papel === 'dono') return ativarDono($)
182  if (papel === 'fase') return ativarFase($)
183  if (papel === null) {
184    desistiu = true
185    $.ui.toast('mensageiro: a ponte ork-ponte não carregou; o Mensageiro de Sessões não leva respostas nesta sessão, e mensagem de outra sessão é consumida antes do modelo')
186    return
187  }
188  const espera = ESPERAS_DO_PAPEL_MS[tentativa] ?? ESPERA_LONGA_DO_PAPEL_MS
189  $.clock.after(espera, () => { emSegundoPlano($, conferirPapel($, tentativa + 1), 'a conferência do papel') })
190}
191
192/** Uma leitura daqui a pouco, uma por vez: os ledgers que mudam juntos entram nela. */
193function agendar($: EngineInterface, ms = ESPERA_DO_LOTE_MS): void {
194  if (lote) return
195  lote = $.clock.after(Math.max(1, ms), () => {
196    lote = undefined
197    emSegundoPlano($, levar($), 'a leitura da caixa de saída')
198  })
199}
200
201/** Uma rodada por vez; pedido que chega no meio volta a rodar logo depois, e quem pediu espera as duas. */
202async function levar($: EngineInterface): Promise<void> {
203  if (!ativa) return
204  if (lendo) {
205    pendente = true
206    return lendo
207  }
208  lendo = (async () => {
209    try {
210      do {
211        pendente = false
212        await umaRodada($)
213      } while (pendente)
214    } finally {
215      lendo = undefined
216    }
217  })()
218  return lendo
219}
220
221/**
222 * Uma rodada: a caixa de saida (o `listar` tambem mantem o Mensageiro vivo para o nucleo, D9), o envio do que deve ir
223 * (D10), o retrato da secao `hitl` para saber o que ja foi respondido e o que segue noutra sessao, a faixa e o registro
224 * do que o painel mostrou.
225 */
226async function umaRodada($: EngineInterface): Promise<void> {
227  let caixa: EntradaDaCaixa[]
228  try {
229    caixa = lerCaixa(jsonDoMcp(await comPrazo($, $.mcp.call('orkastery', 'ork_session_outbox', { operacao: 'listar' })), FERRAMENTA_DA_CAIXA))
230  } catch (erro) {
231    await gravarDono($, { ...dono, erro: `a caixa de saída não foi lida: ${mensagem(erro)}` })
232    await publicarFaixa($)
233    return
234  }
235  const agora = await $.clock.now()
236  const levadas: EntradaDaCaixa[] = []
237  for (const e of caixa) levadas.push(deveEnviar(e, agora) ? await enviar($, e) : e)
238  let retrato: unknown = null
239  try {
240    retrato = (await comPrazo($, $.ork.retrato({ secao: 'hitl' }))).retrato
241  } catch {
242    // Sem o retrato, nada se conclui sobre o que saiu da caixa nesta volta.
243  }
244  const noRetrato = retrato === null ? null : pedidosNoRetrato(retrato)
245  const depois = await $.clock.now()
246  const entregas = atualizarEntregas(dono.entregas, levadas, noRetrato, depois)
247  // So e "noutra sessao" o pedido incerto que esta sessao nao conhece: o que saiu da caixa daqui fica na linha dele.
248  await gravarDono($, {
249    entregas,
250    incertas: retrato === null ? dono.incertas : incertasDoRetrato(retrato).filter(i => !entregas.some(e => e.pedidoId === i.pedidoId)),
251    lidoEm: depois,
252    erro: null,
253  })
254  await publicarFaixa($)
255  await registrarMostrados($)
256}
257
258/** Envia a mensagem como o nucleo a compos e anota o resultado no nucleo; a anotacao que falha nao desfaz o envio. */
259async function enviar($: EngineInterface, e: EntradaDaCaixa): Promise<EntradaDaCaixa> {
260  let r: { entregue: boolean; motivo: string | null }
261  try {
262    r = resultadoDoEnvio(await comPrazo($, $.session.send(argsDoEnvio(e))))
263  } catch (erro) {
264    r = { entregue: false, motivo: `o envio falhou: ${mensagem(erro)}` }
265  }
266  try {
267    const anotada = entradaAnotada(jsonDoMcp(await comPrazo($, $.mcp.call('orkastery', 'ork_session_outbox', argsDaAnotacao(e, r))), FERRAMENTA_DA_CAIXA), e.reservada)
268    if (anotada) return anotada
269  } catch {
270    // O nucleo nao anotou: a proxima leitura da caixa traz a conta dele.
271  }
272  return { ...e, tentativas: e.tentativas + 1, ultimoEnvio: { em: await $.clock.now(), entregue: r.entregue, motivo: r.motivo } }
273}
274
275/** A linha da faixa pela ponte (D7 do plano dos mods), so quando o texto muda; sem nada a dizer, a linha sai. */
276async function publicarFaixa($: EngineInterface): Promise<void> {
277  const texto = textoDaFaixa(dono, await $.clock.now())
278  if (texto === ultimaFaixa) return
279  try {
280    if (texto === undefined) await $.ork.faixa({ mod: MOD })
281    else await $.ork.faixa({ mod: MOD, texto })
282    ultimaFaixa = texto
283  } catch {
284    // A ponte recusou ou falhou: a proxima rodada tenta de novo.
285  }
286}
287
288/** O painel do Mensageiro esta posto e a vista do dono. */
289async function painelAVista($: EngineInterface): Promise<boolean> {
290  const paineis = await $.ui.panes().catch(() => [])
291  return paineis.some(p => p.id === PAINEL && p.isPlaced && p.isShown)
292}
293
294/**
295 * O que o desenho pos na janela de rolagem do painel posto e a vista vai ao registro da ponte (M5 do dono), uma vez
296 * por chave e texto: o Telegram nao repete o que o dono ja viu no terminal.
297 */
298async function registrarMostrados($: EngineInterface): Promise<void> {
299  if (vistosNoDesenho.size === 0) return
300  if (!await painelAVista($)) { vistosNoDesenho.clear(); return }
301  for (const [vista, m] of [...vistosNoDesenho]) {
302    vistosNoDesenho.delete(vista)
303    if (jaMostrado.has(vista)) continue
304    try {
305      await $.ork.mostrado({ mod: MOD, canal: 'painel', chave: m.chave, ...(m.thread ? { thread: m.thread } : {}), texto: m.texto })
306      jaMostrado.add(vista)
307    } catch {
308      // A ponte recusou ou falhou: volta para a proxima vez.
309      vistosNoDesenho.set(vista, m)
310    }
311  }
312}
313
314/**
315 * Anota o que chegou no estado da fase e, quando a mensagem nao entrou como devia, uma linha no transcript (dim, fora
316 * do modelo). Nunca lanca: o receptor decide sem depender do estado nem do transcript.
317 */
318async function anotar($: EngineInterface, mudar: (f: MensageiroFase) => MensageiroFase, registro: MensageiroRecebida | null): Promise<void> {
319  try {
320    await gravarFase($, mudar(fase))
321  } catch (erro) {
322    problemaSemAviso = `o estado da sessão de fase não foi gravado: ${mensagem(erro)}`
323  }
324  const linha = registro ? registroDaRecebida(registro) : null
325  if (!linha) return
326  try {
327    $.ui.log(linha)
328  } catch (erro) {
329    problemaSemAviso = `o transcript recusou a linha do Mensageiro: ${mensagem(erro)}`
330  }
331}
332
333/** Consome a mensagem de outra sessao antes do modelo, com o motivo no estado, no transcript e no registro do Claude Code. */
334async function consumir($: EngineInterface, e: SessionReceiveInput, motivo: string, detalhe?: string): Promise<SessionReceiveResult> {
335  const r: MensageiroRecebida = { em: await instante($), origem: nomeDaOrigem(e.origin), estado: 'consumida', ...idsDaMarca(e.text),
336    motivo: detalhe ? `${motivo}: ${umaLinha(detalhe, 160)}` : motivo, impressao: null }
337  await anotar($, f => comRecebida(f, r), r)
338  return { consumed: motivoDoConsumo(motivo) }
339}
340
341/**
342 * A mensagem de outra sessao (T9.3). Na sessao de fase: sem a marca, consumida sem chamar o nucleo (D13); com ela, vai
343 * ao `conferir` do nucleo, e so a confirmada segue para a conversa, no envelope fixo do Mensageiro e com o corpo que o
344 * nucleo conferiu (D23, que reve a D11), com o `confirmar` depois de entrar na fila. Falha, prazo vencido e resposta
345 * fora do contrato consomem. Sem a ponte, ou sem o papel, consome. Nas sessoes do dono e dormente, passa como chegou.
346 */
347async function receber($: EngineInterface, e: SessionReceiveInput, next: (e: SessionReceiveInput) => Promise<SessionReceiveResult>): Promise<SessionReceiveResult> {
348  let contexto = contextoVisto
349  if (contexto === null) {
350    try {
351      contexto = await contextoDaSessao($)
352    } catch (erro) {
353      return consumir($, e, 'entrega.sem-papel', mensagem(erro))
354    }
355    if (contexto === null) return consumir($, e, 'entrega.sem-ponte')
356  }
357  if (contexto.papel !== 'fase') return next(e)
358  if (typeof e.text !== 'string' || e.text.length > LIMITE_DA_MENSAGEM) return consumir($, e, 'entrega.grande')
359  if (!temMarca(e.text)) return consumir($, e, 'entrega.sem-marca')
360  const thread = contexto.thread
361  if (thread === null) return consumir($, e, 'entrega.despacho')
362  // A mensagem que o nucleo vai conferir, sem o envelope do motor: e so ela que o modelo vai ler (D23).
363  const conferivel = mensagemSemEnvelope(e.text)
364  if (conferivel === null) return consumir($, e, 'entrega.formato')
365  let conferencia: Conferencia
366  try {
367    const agora = await $.clock.now()
368    conferencia = lerConferencia(jsonDoMcp(await comPrazo($, $.mcp.call('orkastery', 'ork_session_inbox', argsDaConferencia(thread, e.text, agora))), FERRAMENTA_DA_CONFERENCIA))
369  } catch (erro) {
370    return consumir($, e, 'entrega.conferencia-falhou', mensagem(erro))
371  }
372  if (!conferencia.confirmada) return consumir($, e, conferencia.motivo)
373  const { pedidoId, envioId } = conferencia
374  const texto = e.text
375  const paraOModelo = textoParaOModelo(conferivel)
376  // A impressao da mensagem como a linha da conversa a mostra, para o rotulo visual.
377  const impressao = impressaoDoTexto(conferivel)
378  confirmadasEmVoo.set(texto, paraOModelo)
379  let r: SessionReceiveResult
380  try {
381    const chegada: MensageiroRecebida = { em: await instante($), origem: nomeDaOrigem(e.origin), estado: 'confirmando', pedidoId, envioId,
382      motivo: null, impressao }
383    await anotar($, f => comRecebida(f, chegada), null)
384    r = await next({ ...e, text: paraOModelo })
385  } finally {
386    confirmadasEmVoo.delete(texto)
387  }
388  if (r.consumed !== undefined) {
389    const caiu: MensageiroRecebida = { em: await instante($), origem: nomeDaOrigem(e.origin), estado: 'nao-entrou', pedidoId, envioId,
390      motivo: `outro plugin a consumiu abaixo do Mensageiro (${umaLinha(r.consumed, 120)})`, impressao: null }
391    await anotar($, f => mudarRecebida(f, envioId, caiu.estado, caiu.motivo, caiu.em), caiu)
392    return r
393  }
394  emSegundoPlano($, confirmar($, thread, pedidoId, envioId, texto, 0), 'a confirmação da entrega')
395  return r
396}
397
398/** O recibo da entrega que entrou na conversa; a falha volta nas esperas e, esgotadas, a linha fica sem recibo. */
399async function confirmar($: EngineInterface, thread: string, pedidoId: string, envioId: string, texto: string, tentativa: number): Promise<void> {
400  let r: { ok: boolean; definitiva: boolean; motivo: string | null }
401  try {
402    r = lerConfirmacao(jsonDoMcp(await comPrazo($, $.mcp.call('orkastery', 'ork_session_inbox', argsDaConfirmacao(thread, envioId, texto))), FERRAMENTA_DA_CONFERENCIA))
403  } catch (erro) {
404    r = { ok: false, definitiva: false, motivo: mensagem(erro) }
405  }
406  const em = await instante($)
407  if (r.ok) return anotar($, f => mudarRecebida(f, envioId, 'entregue', null, em), null)
408  const motivo = r.motivo
409  const espera = r.definitiva ? undefined : ESPERAS_DA_CONFIRMACAO_MS[tentativa]
410  if (espera === undefined) {
411    const semRecibo: MensageiroRecebida = { em, origem: '', estado: 'sem-recibo', pedidoId, envioId, motivo, impressao: null }
412    return anotar($, f => mudarRecebida(f, envioId, 'sem-recibo', motivo, em), semRecibo)
413  }
414  await anotar($, f => mudarRecebida(f, envioId, 'confirmando', `o recibo volta a ser gravado: ${motivo ?? 'sem motivo'}`, em), null)
415  $.clock.after(espera, () => { emSegundoPlano($, confirmar($, thread, pedidoId, envioId, texto, tentativa + 1), 'a confirmação da entrega') })
416}
417
418export const register: Register = on => {
419  on('session.start', async ($, e, next) => {
420    const iniciada = await next(e)
421    await conferirPapel($, 0).catch(() => undefined)
422    return iniciada
423  })
424
425  // Toda entrega que chega a sessao. So a de outra sessao passa pelo receptor (G4); Remote Control, notificacao de
426  // tarefa, agendamento, canais, agentes e teammates verificados desta sessao seguem como chegaram, sem chamada ao
427  // nucleo. O `.catch` fecha: a mensagem de outra sessao que o gancho nao decidiu e consumida, salvo a que o nucleo
428  // ja confirmou e reservou, que segue.
429  on('session.receive', async ($, e, next) => {
430    if (classificarOrigem(e.origin, e.agentId) !== 'outra-sessao') return next(e)
431    return receber($, e, next)
432  }).catch(($, e, next) => {
433    if (next.called) return next(e)
434    const decisao = decisaoNaFalha({ origem: e.origin, agentId: e.agentId, texto: e.text, papel: contextoVisto?.papel ?? null, emVoo: confirmadasEmVoo })
435    if (decisao.acao === 'passa') return next(e)
436    if (decisao.acao === 'conferida') return next({ ...e, text: decisao.texto })
437    return { consumed: motivoDoConsumo('entrega.conferencia-falhou') }
438  })
439
440  // O ledger de uma thread mudou (uma resposta entrou na caixa, a sessao reservou ou confirmou): a leitura entra no
441  // proximo lote. Os caminhos vigiados sao os que a ponte pede para o papel dono; o Mensageiro nao pede outros.
442  on('classic.FileChanged', async ($, e, next) => {
443    if (ativa && threadDoLedger(e.file_path) !== null) agendar($)
444    return next(e)
445  }).catch(($, e, next) => next(e))
446
447  // `/mensageiro` abre o painel: na sessao do dono, as entregas, e le a caixa agora; na de fase, o que chegou.
448  on('command.run', { command: 'mensageiro' }, async ($) => {
449    if (!ativa && !naFase && !desistiu) await conferirPapel($, ESPERAS_DO_PAPEL_MS.length)
450    if (!ativa && !naFase) return { text: 'O Mensageiro de Sessões só abre numa sessão do Orkastery: a do dono ou a de uma fase.' }
451    const aberto = await $.ui.open({ id: PAINEL, title: TITULO })
452    if (ativa) emSegundoPlano($, levar($), 'a leitura da caixa de saída')
453    return { text: aberto.isPlaced ? 'Mensageiro de Sessões aberto.' : `Mensageiro de Sessões aberto, à espera de lugar: ${aberto.reason}` }
454  })
455
456  // O painel: na sessao do dono, as entregas desta sessao e as que seguem noutra; na de fase, o que chegou. O desenho
457  // nunca escreve: so anota o que pos na janela de rolagem, para o registro do mostrado (M5).
458  on('ui.render', { component: 'Pane', requestId: PAINEL }, async ($, e) => {
459    const { Box, Text, Button } = $.ui.resolve(e)
460    if (naFase) {
461      const daFase = (await $.state.get(FASE)).value ?? fase
462      const comProblema: MensageiroFase = problemaSemAviso ? { ...daFase, erro: problemaSemAviso } : daFase
463      return desenharPainel({ Box, Text, Button }, linhasDaFase(comProblema, await $.clock.now()), {})
464    }
465    const atual = (await $.state.get(DONO)).value ?? dono
466    const comProblema: MensageiroDono = problemaSemAviso ? { ...atual, erro: problemaSemAviso } : atual
467    const linhas = linhasDoDono(comProblema, await $.clock.now())
468    for (const m of mostradosVisiveis(linhas, e.props.scroll)) vistosNoDesenho.set(`${m.chave}|${m.texto}`, m)
469    return desenharPainel({ Box, Text, Button }, linhas, { atualizar: () => { emSegundoPlano($, levar($), 'a leitura da caixa de saída') } })
470  })
471
472  // Na sessao de fase, a linha da resposta que o nucleo confirmou mostra o rotulo no lugar do cabecalho. So visual: o
473  // que fica guardado e o que o modelo le nao mudam.
474  on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
475    // So a linha de mensagem de outra sessao (`peer`): a de teammate chega `unclassified`, e a do dono, de outra origem.
476    if (!naFase || e.props.origin.kind !== 'peer' || !temMarca(e.props.text)) return next(e)
477    const daFase = (await $.state.get(FASE)).value ?? fase
478    const texto = textoComRotulo(e.props.text, confirmadasDaFase(daFase))
479    return texto === null ? next(e) : next({ ...e, props: { ...e.props, text: texto } })
480  }).catch(($, e, next) => next(e))
481}
482
hooks/caixa.ts 216 lines
1// O lado do dono do Mensageiro de Sessoes (RM-059, fatia F9, T9.2), em funcoes puras. A sessao do dono que recebeu a
2// resposta pelo dialogo nativo tem a mensagem na caixa de saida do servidor MCP dela (`ork_session_outbox`), so na
3// memoria; o Mensageiro a leva a sessao de fase por `$.session.send`, sem mexer no texto, e anota o envio no nucleo.
4// O que segue incerto noutra sessao do dono sai do retrato da ponte (secao `hitl`). Nada daqui guarda o texto da
5// resposta no estado (D12 da thread ork-rm059mensag). So importa tipos.
6import type { MensageiroDono, MensageiroEntrega, MensageiroEstadoDaEntrega, MensageiroIncerta } from '../types'
7
8export const SERVIDOR = 'orkastery'
9/** A ferramenta da caixa de saida, so no servidor do dono do Claude Code (T9.1). */
10export const FERRAMENTA_DA_CAIXA = 'ork_session_outbox'
11/** O contrato da entrega no nucleo (`core/src/entrega-por-mod.ts`), conferido pelo teste do nucleo. */
12export const CONTRATO_DA_ENTREGA = 'ork.session-delivery/v1'
13/** D10: o envio que nao entrou na fila volta depois do recuo; o que entrou e nao foi reservado, depois da espera. */
14export const RECUO_DO_ENVIO_MS = 30000
15export const ESPERA_DA_RESERVA_MS = 60000
16export const MAXIMO_DE_ENVIOS = 10
17/** D21: a reserva sem recibo por este tempo deixa de ser "confirmando" e pede o dono. */
18export const ESPERA_DO_RECIBO_MS = 5 * 60000
19/** A faixa diz "entregue" por este tempo; o painel guarda o que terminou por este outro. */
20export const FAIXA_DA_ENTREGA_MS = 10 * 60000
21export const GUARDA_DO_TERMINADO_MS = 30 * 60000
22export const MAXIMO_DE_ENTREGAS = 50
23export const DONO_VAZIO: MensageiroDono = { entregas: [], incertas: [], lidoEm: null, erro: null }
24
25const UUID = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/
26const ID_DE_THREAD = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,79}$/
27const VIVOS: readonly MensageiroEstadoDaEntrega[] = ['na-fila', 'a-caminho', 'reservada', 'nao-chegou']
28
29/** Uma entrega da caixa de saida como o nucleo a lista, com o ultimo envio em ms. */
30export type EntradaDaCaixa = {
31  thread: string; pedidoId: string; envioId: string; sessionId: string
32  /** A mensagem inteira, com o cabecalho do nucleo: vai ao `$.session.send` como esta e nunca ao estado. */
33  mensagem: string
34  tentativas: number
35  ultimoEnvio: { em: number; entregue: boolean; motivo: string | null } | null
36  reservada: boolean
37  /** Quando a entrega entrou na caixa (ms desde a epoca); conta o sem-recibo quando o envio nao foi anotado. */
38  criadaEm: number | null
39}
40
41const objeto = (v: unknown): v is Record<string, any> => !!v && typeof v === 'object' && !Array.isArray(v)
42const uuid = (v: unknown): v is string => typeof v === 'string' && UUID.test(v)
43
44/** Uma linha so, sem caractere de controle e com teto: o que vem do nucleo ou de um erro nunca quebra a tela. */
45export function umaLinha(v: unknown, teto = 200): string {
46  const s = typeof v === 'string' ? v : v instanceof Error ? v.message : String(v)
47  return Array.from(s.replace(/\p{Cc}/gu, ' ').replace(/\s+/g, ' ').trim()).slice(0, teto).join('')
48}
49
50/** O JSON que uma ferramenta do nucleo devolveu; a recusa vira erro com o motivo dela. */
51export function jsonDoMcp(r: unknown, ferramenta: string): unknown {
52  const o = objeto(r) ? r : {}
53  const conteudo = Array.isArray(o.content) ? o.content : []
54  const texto = conteudo.filter(c => objeto(c) && c.type === 'text' && typeof c.text === 'string').map(c => c.text).join('')
55  let dados: unknown
56  try { dados = JSON.parse(texto) } catch { throw new Error(`${ferramenta} respondeu fora do JSON`) }
57  if (o.isError === true) throw new Error(`${ferramenta} recusou: ${umaLinha(objeto(dados) && typeof dados.erro === 'string' ? dados.erro : texto)}`)
58  return dados
59}
60
61function lerEntrada(e: unknown): EntradaDaCaixa[] {
62  if (!objeto(e) || typeof e.thread !== 'string' || !ID_DE_THREAD.test(e.thread) || !uuid(e.pedidoId) || !uuid(e.envioId) ||
63      !uuid(e.sessionId) || typeof e.mensagem !== 'string' ||
64      !e.mensagem.startsWith(`[${CONTRATO_DA_ENTREGA} pedido=${e.pedidoId} entrega=${e.envioId}] `)) return []
65  const u = objeto(e.ultimoEnvio) ? e.ultimoEnvio : null
66  const em = u && typeof u.em === 'string' ? Date.parse(u.em) : NaN
67  const criadaEm = typeof e.criadaEm === 'string' ? Date.parse(e.criadaEm) : NaN
68  return [{ thread: e.thread, pedidoId: e.pedidoId, envioId: e.envioId, sessionId: e.sessionId, mensagem: e.mensagem,
69    tentativas: Number.isSafeInteger(e.tentativas) && e.tentativas > 0 ? e.tentativas : 0,
70    ultimoEnvio: u && Number.isFinite(em) ? { em, entregue: u.entregue === true, motivo: typeof u.motivo === 'string' ? umaLinha(u.motivo) : null } : null,
71    reservada: e.reservada === true, criadaEm: Number.isFinite(criadaEm) ? criadaEm : null }]
72}
73
74/** A caixa de saida do `listar`; o que nao bate com o contrato fica de fora, e a resposta fora do contrato e erro. */
75export function lerCaixa(dados: unknown): EntradaDaCaixa[] {
76  if (!objeto(dados) || dados.contrato !== CONTRATO_DA_ENTREGA || !Array.isArray(dados.entregas)) {
77    throw new Error('a caixa de saída respondeu fora do contrato ork.session-delivery/v1')
78  }
79  return dados.entregas.flatMap(lerEntrada)
80}
81
82/** A entrega que o `enviada` devolveu, com a reserva que ela tinha na lista (o nucleo so a le no `listar`). */
83export function entradaAnotada(dados: unknown, reservada: boolean): EntradaDaCaixa | null {
84  const lida = objeto(dados) && dados.contrato === CONTRATO_DA_ENTREGA ? lerEntrada({ ...(objeto(dados.entrega) ? dados.entrega : {}), reservada }) : []
85  return lida[0] ?? null
86}
87
88/** D10: envia o que nunca foi, o que nao entrou na fila depois do recuo e o que entrou e ninguem reservou depois da espera. */
89export function deveEnviar(e: EntradaDaCaixa, agora: number): boolean {
90  if (e.reservada || e.tentativas >= MAXIMO_DE_ENVIOS) return false
91  if (!e.ultimoEnvio) return true
92  return agora - e.ultimoEnvio.em >= (e.ultimoEnvio.entregue ? ESPERA_DA_RESERVA_MS : RECUO_DO_ENVIO_MS)
93}
94
95/** O argumento do `$.session.send`: a sessao do pedido e a mensagem do nucleo, sem mexer em nada. */
96export function argsDoEnvio(e: EntradaDaCaixa): { to: { sessionId: string }; text: string } {
97  return { to: { sessionId: e.sessionId }, text: e.mensagem }
98}
99
100/** O resultado do `$.session.send` como o nucleo o anota. */
101export function resultadoDoEnvio(r: unknown): { entregue: boolean; motivo: string | null } {
102  if (objeto(r) && r.isDelivered === true) return { entregue: true, motivo: null }
103  return { entregue: false, motivo: umaLinha(objeto(r) && typeof r.reason === 'string' && r.reason ? r.reason : 'a sessão não recebeu a mensagem') }
104}
105
106export function argsDaAnotacao(e: EntradaDaCaixa, r: { entregue: boolean; motivo: string | null }): Record<string, unknown> {
107  return { operacao: 'enviada', threadId: e.thread, envioId: e.envioId, entregue: r.entregue, ...(r.motivo ? { motivo: r.motivo } : {}) }
108}
109
110/** A secao `hitl` do retrato, quando ela veio lida (disponivel ou vazia); `null` quando nao da para concluir nada. */
111function secaoHitl(retrato: unknown): Record<string, any>[] | null {
112  const secoes = objeto(retrato) && objeto(retrato.sections) ? retrato.sections : {}
113  const hitl = objeto(secoes.hitl) ? secoes.hitl : null
114  if (!hitl || (hitl.state !== 'available' && hitl.state !== 'empty')) return null
115  return Array.isArray(hitl.items) ? hitl.items.filter(objeto) : []
116}
117
118const threadDoItem = (i: Record<string, any>): string | null => {
119  const ref = Array.isArray(i.refs) ? i.refs.find((r: unknown) => objeto(r) && r.kind === 'thread' && typeof r.id === 'string') : null
120  return ref && ID_DE_THREAD.test(ref.id) ? ref.id : null
121}
122
123/** Os pedidos abertos que o retrato mostra (respondido sai da secao); `null` quando a secao nao veio lida. */
124export function pedidosNoRetrato(retrato: unknown): Set<string> | null {
125  const itens = secaoHitl(retrato)
126  return itens === null ? null : new Set(itens.flatMap(i => (typeof i.id === 'string' ? [i.id] : [])))
127}
128
129/** Os pedidos de sessao com a entrega incerta no retrato (`delivery.uncertain`): a entrega segue em alguma sessao do dono. */
130export function incertasDoRetrato(retrato: unknown): MensageiroIncerta[] {
131  return (secaoHitl(retrato) ?? []).flatMap(i => {
132    const thread = threadDoItem(i)
133    return i.status === 'delivery.uncertain' && objeto(i.facts) && i.facts.target === 'session' && uuid(i.id) && thread
134      ? [{ thread, pedidoId: i.id }] : []
135  })
136}
137
138/** O estado de uma entrega que esta na caixa, agora. */
139export function estadoDaEntrada(e: EntradaDaCaixa, agora: number): MensageiroEstadoDaEntrega {
140  if (e.reservada) {
141    const desde = e.ultimoEnvio?.em ?? e.criadaEm
142    return typeof desde === 'number' && agora - desde >= ESPERA_DO_RECIBO_MS ? 'sem-recibo' : 'reservada'
143  }
144  if (!e.ultimoEnvio) return 'na-fila'
145  if (e.tentativas >= MAXIMO_DE_ENVIOS) return 'desistiu'
146  return e.ultimoEnvio.entregue ? 'a-caminho' : 'nao-chegou'
147}
148
149function motivoDaEntrada(e: EntradaDaCaixa, estado: MensageiroEstadoDaEntrega): string | null {
150  if (estado === 'sem-recibo') return 'a sessão de fase reservou a entrega e não gravou o recibo em 5 min'
151  if (estado === 'desistiu') {
152    return e.ultimoEnvio?.entregue
153      ? `a sessão de fase não confirmou a entrega depois de ${e.tentativas} envios`
154      : e.ultimoEnvio?.motivo ?? `a mensagem não entrou na fila da sessão em ${e.tentativas} envios`
155  }
156  return estado === 'nao-chegou' ? e.ultimoEnvio?.motivo ?? null : null
157}
158
159/**
160 * As entregas desta sessao depois de uma leitura da caixa. A que saiu da caixa terminou: entregue, quando o retrato
161 * nao mostra mais o pedido (respondido); "saiu da caixa", quando o pedido segue aberto. Sem o retrato, nada se conclui.
162 */
163export function atualizarEntregas(anteriores: readonly MensageiroEntrega[], caixa: readonly EntradaDaCaixa[],
164  noRetrato: Set<string> | null, agora: number): MensageiroEntrega[] {
165  const restantes = new Map(anteriores.map(e => [e.envioId, e]))
166  const saida: MensageiroEntrega[] = []
167  for (const c of caixa) {
168    const estado = estadoDaEntrada(c, agora)
169    const antes = restantes.get(c.envioId)
170    restantes.delete(c.envioId)
171    saida.push({ thread: c.thread, pedidoId: c.pedidoId, envioId: c.envioId, sessionId: c.sessionId, estado, tentativas: c.tentativas,
172      motivo: motivoDaEntrada(c, estado), em: antes && antes.estado === estado ? antes.em : agora })
173  }
174  for (const e of restantes.values()) {
175    if (!VIVOS.includes(e.estado) && e.estado !== 'desistiu' && e.estado !== 'sem-recibo') {
176      if (agora - e.em <= GUARDA_DO_TERMINADO_MS) saida.push(e)
177      continue
178    }
179    if (noRetrato === null) { saida.push(e); continue }
180    const aberto = noRetrato.has(e.pedidoId)
181    saida.push({ ...e, estado: aberto ? 'saiu-da-caixa' : 'entregue', em: agora,
182      motivo: aberto ? 'a caixa de saída soltou a entrega sem recibo; responda pelo claude attach' : null })
183  }
184  return saida.slice(-MAXIMO_DE_ENTREGAS)
185}
186
187/** A thread do ledger que mudou, pelos caminhos que a ponte vigia (`.orkastery/threads/<thread>/ledger.jsonl`). */
188export function threadDoLedger(caminho: unknown): string | null {
189  if (typeof caminho !== 'string') return null
190  const m = /\.orkastery\/threads\/([^/]+)\/ledger\.jsonl$/.exec(caminho)
191  return m && ID_DE_THREAD.test(m[1]) ? m[1] : null
192}
193
194function daThread(entregas: readonly MensageiroEntrega[], um: string, varios: (n: number) => string): string {
195  return entregas.length === 1 ? `${um} ${entregas[0].thread}` : varios(entregas.length)
196}
197
198/**
199 * A linha da faixa (D7 do plano dos mods): o que nao chegou e precisa do dono vem primeiro; depois o que esta a caminho;
200 * depois o que acabou de ser entregue. Sem nada disso, a linha sai da faixa.
201 */
202export function textoDaFaixa(dono: MensageiroDono, agora: number): string | undefined {
203  const falhas = dono.entregas.filter(e => e.estado === 'desistiu' || e.estado === 'saiu-da-caixa')
204  if (falhas.length) return falhas.length === 1
205    ? `a resposta para a ${falhas[0].thread} não chegou; responda pelo claude attach` : `${falhas.length} respostas não chegaram; abra /mensageiro`
206  // Sem recibo, a resposta pode ter entrado na conversa: o dono confere antes de responder de novo.
207  const semRecibo = dono.entregas.filter(e => e.estado === 'sem-recibo')
208  if (semRecibo.length) return semRecibo.length === 1
209    ? `a resposta para a ${semRecibo[0].thread} ficou sem recibo; confira pelo claude attach` : `${semRecibo.length} respostas sem recibo; abra /mensageiro`
210  const vivas = dono.entregas.filter(e => VIVOS.includes(e.estado))
211  if (vivas.length) return daThread(vivas, 'resposta a caminho da', n => `${n} respostas a caminho das sessões de fase`)
212  const entregues = dono.entregas.filter(e => e.estado === 'entregue' && agora - e.em <= FAIXA_DA_ENTREGA_MS)
213  if (entregues.length) return daThread(entregues, 'resposta entregue à', n => `${n} respostas entregues às sessões de fase`)
214  return undefined
215}
216
hooks/painel.tsx 166 lines
1// O painel do Mensageiro de Sessoes (RM-059, fatia F9), em funcoes puras: o estado vira linhas da tela, uma por
2// elemento, e as linhas viram a arvore do painel com os elementos que a superficie resolveu (so `Box`, `Text` e
3// `Button`, os mesmos no terminal e no desktop). Todo estado tem rotulo e glifo, nunca so cor (regra da Partitura).
4// O texto da resposta do dono nunca aparece aqui: so a thread, o pedido e o estado da entrega. So importa tipos e
5// modulos puros do Mensageiro.
6import type { MensageiroDono, MensageiroEntrega, MensageiroEstadoDaEntrega, MensageiroEstadoDaRecebida, MensageiroFase, MensageiroMostrado,
7  MensageiroRecebida } from '../types'
8import { umaLinha } from './caixa'
9import { codigoDoMotivo, motivoLegivel } from './recepcao'
10
11export type LinhaDoMensageiro = {
12  tipo: 'titulo' | 'secao' | 'texto' | 'dim' | 'falha' | 'feito'
13  chave: string
14  texto: string
15  /** O que vai ao registro do mostrado da ponte quando a linha esta na janela de rolagem do painel a vista. */
16  mostrado?: MensageiroMostrado
17}
18
19export const ROTULO_DO_ESTADO: Record<MensageiroEstadoDaEntrega, string> = {
20  'na-fila': 'na fila do núcleo',
21  'a-caminho': 'a caminho: na fila da sessão, esperando a conferência',
22  'reservada': 'recebida pela sessão, confirmando',
23  'sem-recibo': 'recebida pela sessão, sem recibo em 5 min: confira pelo claude attach',
24  'nao-chegou': 'não entrou na fila da sessão; nova tentativa em breve',
25  'desistiu': 'não chegou: responda pelo claude attach',
26  'entregue': 'entregue, com o recibo da sessão',
27  'saiu-da-caixa': 'saiu da caixa sem recibo: responda pelo claude attach',
28}
29
30const GLIFO: Record<MensageiroEstadoDaEntrega, string> = {
31  'na-fila': '·', 'a-caminho': '●', 'reservada': '●', 'sem-recibo': '?', 'nao-chegou': '×', 'desistiu': '×', 'entregue': '✓', 'saiu-da-caixa': '?',
32}
33
34const TIPO: Record<MensageiroEstadoDaEntrega, LinhaDoMensageiro['tipo']> = {
35  'na-fila': 'texto', 'a-caminho': 'texto', 'reservada': 'texto', 'sem-recibo': 'falha', 'nao-chegou': 'falha', 'desistiu': 'falha', 'entregue': 'feito', 'saiu-da-caixa': 'falha',
36}
37
38/** Quanto tempo faz, como o dono le. */
39export function idade(ms: number): string {
40  if (!(ms >= 60000)) return 'agora'
41  const min = Math.floor(ms / 60000)
42  return min < 60 ? `há ${min} min` : `há ${Math.floor(min / 60)} h`
43}
44
45const curto = (id: string) => id.slice(0, 8)
46
47function linhasDaEntrega(e: MensageiroEntrega, agora: number): LinhaDoMensageiro[] {
48  const k = (s: string) => `${e.envioId}:${s}`
49  const envios = e.tentativas > 1 ? ` · ${e.tentativas} envios` : ''
50  const texto = `${GLIFO[e.estado]} ${e.thread} · pedido ${curto(e.pedidoId)} · ${ROTULO_DO_ESTADO[e.estado]}${envios} · ${idade(agora - e.em)}`
51  const linhas: LinhaDoMensageiro[] = [{ tipo: TIPO[e.estado], chave: k('estado'), texto: umaLinha(texto, 300),
52    mostrado: { chave: `hitl:${e.pedidoId}`, thread: e.thread, texto: `${e.thread}: resposta do dono ${ROTULO_DO_ESTADO[e.estado]}` } }]
53  if (e.motivo) linhas.push({ tipo: 'dim', chave: k('motivo'), texto: `  ${umaLinha(e.motivo, 200)}` })
54  return linhas
55}
56
57/** As linhas do painel na sessao do dono. */
58export function linhasDoDono(dono: MensageiroDono, agora: number): LinhaDoMensageiro[] {
59  const linhas: LinhaDoMensageiro[] = [
60    { tipo: 'titulo', chave: 'titulo', texto: 'Mensageiro de Sessões' },
61    { tipo: 'dim', chave: 'o-que-faz', texto: 'Leva a resposta que você deu pelo diálogo à sessão de fase que parou esperando por ela.' },
62  ]
63  if (dono.erro) linhas.push({ tipo: 'falha', chave: 'erro', texto: `× ${umaLinha(dono.erro, 200)}` })
64  linhas.push({ tipo: 'secao', chave: 'secao-entregas', texto: 'Respostas desta sessão' })
65  if (!dono.entregas.length) linhas.push({ tipo: 'dim', chave: 'sem-entregas', texto: '  nenhuma resposta a caminho agora' })
66  for (const e of [...dono.entregas].reverse()) linhas.push(...linhasDaEntrega(e, agora))
67  if (dono.incertas.length) {
68    // O retrato so diz que a entrega esta incerta (sem o runtime): ela pode seguir noutra sessao do dono ou noutro canal
69    // (um envio nativo pendente), ou ter se perdido com uma sessao que fechou. O painel nao sabe qual, e diz isso.
70    linhas.push({ tipo: 'secao', chave: 'secao-incertas', texto: 'Fora desta sessão' })
71    for (const i of dono.incertas) {
72      linhas.push({ tipo: 'texto', chave: `incerta:${i.pedidoId}`,
73        texto: `· ${i.thread} · pedido ${curto(i.pedidoId)} · entrega incerta fora desta sessão: noutra sessão do dono, noutro canal ou perdida` })
74    }
75    linhas.push({ tipo: 'dim', chave: 'incertas-limite', texto: '  sem retorno, responda pelo claude attach' })
76  }
77  linhas.push({ tipo: 'dim', chave: 'limite', texto: 'A resposta chega como mensagem de outra sessão: informa a sessão de fase, não autoriza ação que só você aprova. Para isso, claude attach.' })
78  if (dono.lidoEm === null) linhas.push({ tipo: 'dim', chave: 'lido', texto: 'a caixa de saída ainda não foi lida' })
79  else linhas.push({ tipo: 'dim', chave: 'lido', texto: `caixa de saída lida ${idade(agora - dono.lidoEm)}` })
80  return linhas
81}
82
83export const ROTULO_DA_RECEBIDA: Record<MensageiroEstadoDaRecebida, string> = {
84  'consumida': 'consumida antes do modelo',
85  'confirmando': 'entrou na conversa; gravando o recibo no núcleo',
86  'entregue': 'entrou na conversa, com o recibo no núcleo',
87  'sem-recibo': 'entrou na conversa, mas o recibo não foi gravado',
88  'nao-entrou': 'conferida, mas não entrou na conversa',
89}
90
91const GLIFO_DA_RECEBIDA: Record<MensageiroEstadoDaRecebida, string> = {
92  'consumida': '○', 'confirmando': '●', 'entregue': '✓', 'sem-recibo': '?', 'nao-entrou': '×',
93}
94
95const TIPO_DA_RECEBIDA: Record<MensageiroEstadoDaRecebida, LinhaDoMensageiro['tipo']> = {
96  'consumida': 'texto', 'confirmando': 'texto', 'entregue': 'feito', 'sem-recibo': 'falha', 'nao-entrou': 'falha',
97}
98
99function linhasDaRecebida(r: MensageiroRecebida, i: number, agora: number): LinhaDoMensageiro[] {
100  const k = (s: string) => `recebida:${i}:${s}`
101  const quem = r.estado === 'consumida'
102    ? `mensagem de outra sessão (${umaLinha(r.origem, 80)})${r.pedidoId ? ` com a marca do pedido ${curto(r.pedidoId)}` : ''}`
103    : `resposta do dono ao pedido ${r.pedidoId ? curto(r.pedidoId) : '?'}`
104  const linhas: LinhaDoMensageiro[] = [{ tipo: TIPO_DA_RECEBIDA[r.estado], chave: k('estado'),
105    texto: umaLinha(`${GLIFO_DA_RECEBIDA[r.estado]} ${quem} · ${ROTULO_DA_RECEBIDA[r.estado]} · ${idade(agora - r.em)}`, 300) }]
106  if (r.motivo) {
107    const motivo = r.estado === 'consumida' ? `${motivoLegivel(r.motivo)} · ${codigoDoMotivo(r.motivo)}` : r.motivo
108    linhas.push({ tipo: 'dim', chave: k('motivo'), texto: `  ${umaLinha(motivo, 200)}` })
109  }
110  return linhas
111}
112
113/**
114 * As linhas do painel na sessao de fase: o que chegou de outras sessoes, o mais novo primeiro. Sem botao: o estado so
115 * muda quando algo chega, e o painel se redesenha sozinho. O texto de nenhuma mensagem aparece aqui.
116 */
117export function linhasDaFase(fase: MensageiroFase, agora: number): LinhaDoMensageiro[] {
118  const linhas: LinhaDoMensageiro[] = [
119    { tipo: 'secao', chave: 'titulo', texto: 'Mensageiro de Sessões' },
120    { tipo: 'dim', chave: 'o-que-faz', texto: `Nesta sessão de fase${fase.thread ? ` da ${fase.thread}` : ''}, só entra na conversa a resposta do dono que o núcleo conferiu; o resto que chega de outra sessão é consumido.` },
121  ]
122  if (fase.erro) linhas.push({ tipo: 'falha', chave: 'erro', texto: `× ${umaLinha(fase.erro, 200)}` })
123  linhas.push({ tipo: 'secao', chave: 'secao-recebidas', texto: 'O que chegou de outras sessões' })
124  if (!fase.recebidas.length) linhas.push({ tipo: 'dim', chave: 'sem-recebidas', texto: '  nada chegou de outra sessão' })
125  const recebidas = fase.recebidas.map((r, i) => ({ r, i })).reverse()
126  for (const { r, i } of recebidas) linhas.push(...linhasDaRecebida(r, i, agora))
127  linhas.push({ tipo: 'dim', chave: 'limite', texto: 'A resposta do dono informa esta sessão; ação que só o dono aprova continua pedindo o dono.' })
128  return linhas
129}
130
131/**
132 * O que esta inteiro dentro da janela de rolagem do painel: so isso conta como mostrado ao dono (M5). Toda linha do
133 * painel trunca numa linha da tela, e a do titulo divide a linha com o botao.
134 */
135export function mostradosVisiveis(linhas: readonly LinhaDoMensageiro[], janela: { offset: number; bodyRows: number } | undefined): MensageiroMostrado[] {
136  if (!janela || !(janela.bodyRows > 0)) return []
137  const fim = janela.offset + janela.bodyRows
138  return linhas.flatMap((l, i) => (l.mostrado && i >= janela.offset && i < fim ? [l.mostrado] : []))
139}
140
141export type Gestos = { atualizar?: () => void }
142
143/** A arvore do painel com os elementos que a superficie resolveu. */
144export function desenharPainel(el: { Box: any; Text: any; Button: any }, linhas: readonly LinhaDoMensageiro[], gestos: Gestos) {
145  const { Box, Text, Button } = el
146  return (
147    <Box flexDirection="column">
148      {linhas.map(l => {
149        if (l.tipo === 'titulo') {
150          return (
151            <Box key="titulo" flexDirection="row">
152              <Text key="titulo-texto" bold wrap="truncate">{l.texto}</Text>
153              <Button key="atualizar" label="Atualizar" onPress={() => gestos.atualizar?.()} />
154            </Box>
155          )
156        }
157        if (l.tipo === 'secao') return <Text key={l.chave} bold wrap="truncate">{l.texto}</Text>
158        if (l.tipo === 'falha') return <Text key={l.chave} color="error" wrap="truncate">{l.texto}</Text>
159        if (l.tipo === 'feito') return <Text key={l.chave} color="success" wrap="truncate">{l.texto}</Text>
160        if (l.tipo === 'dim') return <Text key={l.chave} dimColor wrap="truncate">{l.texto}</Text>
161        return <Text key={l.chave} wrap="truncate">{l.texto}</Text>
162      })}
163    </Box>
164  )
165}
166
hooks/recepcao.ts 257 lines
1// O lado da sessao de fase do Mensageiro de Sessoes (RM-059, fatia F9, T9.3), em funcoes puras. Na sessao de fase,
2// a mensagem que chega de outra sessao so entra na conversa do modelo depois que o nucleo confirma, pela
3// `ork_session_inbox` do servidor preso ao despacho, que ela e a resposta do dono reservada para esta sessao, com o
4// mesmo texto. O nome do plugin remetente (`origin.plugin`) e so o que o remetente declara: entra no registro, nunca
5// na decisao. Nada daqui guarda o texto da mensagem no estado (D12 da thread ork-rm059mensag). So importa tipos e o
6// modulo puro da caixa.
7import type { MensageiroEstadoDaRecebida, MensageiroFase, MensageiroRecebida } from '../types'
8import { CONTRATO_DA_ENTREGA, umaLinha } from './caixa'
9
10/** A ferramenta da conferencia no receptor, so no servidor preso ao despacho (T9.1). */
11export const FERRAMENTA_DA_CONFERENCIA = 'ork_session_inbox'
12/** A maior mensagem que vai a conferencia, o mesmo teto do nucleo (`LIMITE_DA_MENSAGEM` de `entrega-por-mod.ts`). */
13export const LIMITE_DA_MENSAGEM = 8192
14export const MAXIMO_DE_RECEBIDAS = 30
15/** O recibo que nao foi gravado volta nestas esperas; depois delas, a linha fica sem recibo. */
16export const ESPERAS_DA_CONFIRMACAO_MS = [5000, 30000, 120000]
17/**
18 * D21: o prazo que a conferencia leva ao nucleo (`ate`), bem antes do teto de 20 s da chamada no mod. O nucleo nao
19 * reserva depois dele: a conferencia atrasada nao deixa reserva sem ninguem para confirma-la.
20 */
21export const PRAZO_DA_CONFERENCIA_MS = 15000
22export const FASE_VAZIA: MensageiroFase = { thread: null, recebidas: [], erro: null }
23
24const UUID = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/
25const objeto = (v: unknown): v is Record<string, any> => !!v && typeof v === 'object' && !Array.isArray(v)
26const uuid = (v: unknown): v is string => typeof v === 'string' && UUID.test(v)
27const curto = (id: string) => id.slice(0, 8)
28
29/**
30 * As origens que nao trazem texto de outra sessao (G4, D22 da thread ork-rm059mensag): o Remote Control do dono
31 * (`bridge`), a notificacao de tarefa, o agendamento e o canal. A lista e fechada: tipo novo ou desconhecido conta
32 * como outra sessao ate entrar aqui.
33 */
34const EXTERNAS = ['bridge', 'task-notification', 'scheduled-trigger', 'slack-ping']
35/** Entre a conversa principal e os agentes desta sessao: so com o `agentId` do agente de destino. */
36const ENTRE_AGENTES = ['peer', 'coordinator']
37
38export type ClasseDaOrigem = 'outra-sessao' | 'da-propria-sessao' | 'externa'
39
40/**
41 * Classifica uma entrega que chegou: externa (a lista fechada acima), da propria sessao (para um agente desta sessao,
42 * ou de teammate que o harness verificou) ou de outra sessao, que so entra confirmada pelo nucleo. Conta como outra
43 * sessao o mailbox que o harness nao verificou (`isVerified` diferente de `true`, D17: o despacho roda os teammates no
44 * mesmo processo, e ali a entrada sem verificacao e uma que nenhum membro mandou), `peer` e `coordinator` sem agente
45 * desta sessao como destino, inclusive a mensagem de um agente desta sessao para a conversa principal, que chega sem
46 * `agentId` e nao se distingue da de outra sessao (D22), e todo tipo desconhecido ou origem malformada.
47 */
48export function classificarOrigem(origem: unknown, agentId: unknown): ClasseDaOrigem {
49  const o = objeto(origem) ? origem : {}
50  if ('teammate' in o || 'isVerified' in o) return o.isVerified === true ? 'da-propria-sessao' : 'outra-sessao'
51  const kind = typeof o.kind === 'string' ? o.kind : ''
52  if (EXTERNAS.includes(kind)) return 'externa'
53  if (ENTRE_AGENTES.includes(kind) && typeof agentId === 'string' && agentId !== '') return 'da-propria-sessao'
54  return 'outra-sessao'
55}
56
57/** A origem para o registro e o painel, numa linha curta: o tipo e o plugin que o remetente diz ser. */
58export function nomeDaOrigem(origem: unknown): string {
59  const o = objeto(origem) ? origem : {}
60  const kind = typeof o.kind === 'string' && /^[a-z-]{1,40}$/.test(o.kind) ? o.kind : 'desconhecida'
61  const plugin = typeof o.plugin === 'string' && /^[a-zA-Z0-9._-]{1,64}$/.test(o.plugin) ? `, plugin declarado ${o.plugin}` : ''
62  return `${kind}${'teammate' in o || 'isVerified' in o ? ' do mailbox' : ''}${plugin}`
63}
64
65/** O cabecalho que o nucleo compoe (`cabecalhoDaEntrega` de `entrega-por-mod.ts`), conferido pelo teste do nucleo. */
66export function cabecalhoDaEntrega(pedidoId: string, envioId: string): string {
67  return `[${CONTRATO_DA_ENTREGA} pedido=${pedidoId} entrega=${envioId}] Resposta do dono à pergunta deste turno, conferida pelo núcleo do Orkastery:`
68}
69
70/** O rotulo visual da entrega confirmada (`rotuloDaEntrega` do nucleo). */
71export function rotuloDaEntrega(pedidoId: string): string {
72  return `Resposta do dono ao pedido ${curto(pedidoId)}, conferida pelo núcleo`
73}
74
75/** A mensagem traz a marca da entrega do nucleo: so essa vai a conferencia (D13); quem decide e o nucleo. */
76export function temMarca(texto: unknown): boolean {
77  return typeof texto === 'string' && texto.length <= LIMITE_DA_MENSAGEM && texto.includes(`[${CONTRATO_DA_ENTREGA} pedido=`)
78}
79
80/** Os ids que a marca diz ter, sem conferir nada: so para o registro de quem chegou. */
81export function idsDaMarca(texto: unknown): { pedidoId: string | null; envioId: string | null } {
82  const m = typeof texto === 'string' ? /\[ork\.session-delivery\/v1 pedido=([0-9a-f-]{36}) entrega=([0-9a-f-]{36})\]/.exec(texto) : null
83  return { pedidoId: m && uuid(m[1]) ? m[1] : null, envioId: m && uuid(m[2]) ? m[2] : null }
84}
85
86/** O argumento do `conferir`, com o prazo (`ate`) contado do relogio da sessao. */
87export function argsDaConferencia(thread: string, texto: string, agora: number): Record<string, unknown> {
88  return { threadId: thread, operacao: 'conferir', texto, ate: new Date(agora + PRAZO_DA_CONFERENCIA_MS).toISOString() }
89}
90
91export function argsDaConfirmacao(thread: string, envioId: string, texto: string): Record<string, unknown> {
92  return { threadId: thread, operacao: 'confirmar', envioId, texto }
93}
94
95export type Conferencia = { confirmada: true; pedidoId: string; envioId: string } | { confirmada: false; motivo: string }
96
97/** A resposta do `conferir`: so `confirmada: true` com os dois ids passa; o resto e recusa, com o motivo do nucleo. */
98export function lerConferencia(dados: unknown): Conferencia {
99  if (objeto(dados) && dados.confirmada === true && uuid(dados.pedidoId) && uuid(dados.envioId)) {
100    return { confirmada: true, pedidoId: dados.pedidoId, envioId: dados.envioId }
101  }
102  const motivo = objeto(dados) && dados.confirmada === false && typeof dados.motivo === 'string' && /^entrega\.[a-z-]{1,40}$/.test(dados.motivo)
103    ? dados.motivo : 'entrega.fora-do-contrato'
104  return { confirmada: false, motivo }
105}
106
107/** A resposta do `confirmar`: entregue com o recibo; a recusa do nucleo e definitiva; o resto volta a tentar. */
108export function lerConfirmacao(dados: unknown): { ok: boolean; definitiva: boolean; motivo: string | null } {
109  if (objeto(dados) && dados.ok === true && dados.estado === 'entregue') return { ok: true, definitiva: true, motivo: null }
110  const recusada = objeto(dados) && dados.estado === 'recusada'
111  const motivo = objeto(dados) && typeof dados.motivo === 'string' ? dados.motivo
112    : objeto(dados) && typeof dados.estado === 'string' ? `o núcleo respondeu ${dados.estado}` : 'a confirmação respondeu fora do contrato'
113  return { ok: false, definitiva: recusada, motivo: umaLinha(motivo, 120) }
114}
115
116/** O que cada motivo quer dizer, para o painel e para o registro. Os do nucleo sao os de `MotivoDoReceptor`. */
117export const MOTIVO_LEGIVEL: Record<string, string> = {
118  'entrega.sem-marca': 'sem a marca da entrega do núcleo',
119  'entrega.grande': 'maior que qualquer entrega do núcleo',
120  'entrega.formato': 'fora do formato da entrega',
121  'entrega.despacho': 'o despacho desta sessão não confere no núcleo',
122  'entrega.desconhecida': 'o núcleo não tem essa entrega',
123  'entrega.outra-sessao': 'a entrega é de outra sessão',
124  'entrega.texto-diferente': 'o texto não é o da resposta do dono',
125  'entrega.respondida': 'o pedido já foi respondido',
126  'entrega.reservada': 'cópia de uma entrega que já entrou',
127  'entrega.turno-mudou': 'a sessão já saiu do turno do pedido',
128  'entrega.sem-reserva': 'a entrega não foi reservada por esta sessão',
129  'entrega.prazo': 'a conferência chegou ao núcleo depois do prazo',
130  'entrega.fora-do-contrato': 'a conferência respondeu fora do contrato',
131  'entrega.conferencia-falhou': 'a conferência no núcleo falhou',
132  'entrega.sem-ponte': 'a ponte do Orkastery não carregou nesta sessão',
133  'entrega.sem-papel': 'a ponte não disse o papel desta sessão',
134}
135
136/** O codigo de um motivo (`entrega.x`, ou `entrega.x: detalhe`). */
137export function codigoDoMotivo(motivo: string): string {
138  const i = motivo.indexOf(': ')
139  return i < 0 ? motivo : motivo.slice(0, i)
140}
141
142/** O motivo como o dono le, com o detalhe entre parenteses quando ha. */
143export function motivoLegivel(motivo: string): string {
144  const codigo = codigoDoMotivo(motivo)
145  const base = MOTIVO_LEGIVEL[codigo]
146  if (!base) return umaLinha(motivo, 120)
147  return codigo === motivo ? base : `${base} (${umaLinha(motivo.slice(codigo.length + 2), 120)})`
148}
149
150/** O motivo que vai ao `{ consumed }`: o Claude Code o registra com o nome do plugin. */
151export function motivoDoConsumo(motivo: string): string {
152  const codigo = codigoDoMotivo(motivo)
153  return `mensagem de outra sessão sem entrega confirmada pelo núcleo do Orkastery (${MOTIVO_LEGIVEL[codigo] ?? 'motivo desconhecido'}; ${umaLinha(codigo, 60)})`
154}
155
156/** A linha do transcript (dim, fora do modelo) para o que nao entrou na conversa como devia. */
157export function registroDaRecebida(r: MensageiroRecebida): string | null {
158  const pedido = r.pedidoId ? curto(r.pedidoId) : null
159  if (r.estado === 'consumida') return `Mensageiro de Sessões: mensagem de outra sessão (${r.origem}) consumida antes do modelo: ${motivoLegivel(r.motivo ?? '')}.`
160  if (r.estado === 'nao-entrou') return `Mensageiro de Sessões: a resposta do dono ao pedido ${pedido}, conferida pelo núcleo, não entrou na conversa: ${r.motivo ?? 'consumida abaixo do Mensageiro'}.`
161  if (r.estado === 'sem-recibo') return `Mensageiro de Sessões: a resposta do dono ao pedido ${pedido} entrou na conversa, mas o recibo não foi gravado no núcleo (${r.motivo ?? 'sem motivo'}); a entrega segue incerta para o dono.`
162  return null
163}
164
165/** O remetente do envelope fixo com que o receptor entrega a resposta conferida ao modelo (D23). */
166export const REMETENTE_DO_MENSAGEIRO = 'ork-mensageiro'
167
168/**
169 * O texto que a sessao de fase le (D23): a mensagem que o nucleo conferiu, no envelope fixo do Mensageiro. O envelope
170 * que chegou sai inteiro, com os rotulos que o remetente escolhe (o nome da sessao, o plugin, o endereco), e o
171 * enquadramento de mensagem de outra sessao fica. O `from-plugin` vai no proprio envelope: sem ele, o motor
172 * acrescentaria o plugin que o remetente declara na apresentacao. Do remetente, nada passa deste gancho; o anexo de
173 * arquivo do `SendFile`, que o motor poe no envelope depois dos ganchos, e limite dito no guia.
174 */
175export function textoParaOModelo(mensagem: string): string {
176  return `<cross-session-message from="${REMETENTE_DO_MENSAGEIRO}" from-plugin="${REMETENTE_DO_MENSAGEIRO}">\n${mensagem}\n</cross-session-message>`
177}
178
179export type DecisaoNaFalha = { acao: 'passa' } | { acao: 'conferida'; texto: string } | { acao: 'consome' }
180
181/**
182 * O que o `.catch` do receptor faz quando o gancho falha antes de chamar `next` (D18): a entrega que nao e de outra
183 * sessao passa como chegou, como na sessao que nao e de fase; a de outra sessao que o nucleo ja confirmou e reservou
184 * (em voo) segue no envelope do Mensageiro, como no caminho normal; o resto e consumido. Papel desconhecido conta como
185 * fase.
186 */
187export function decisaoNaFalha(f: { origem: unknown; agentId: unknown; texto: unknown; papel: string | null;
188  emVoo: ReadonlyMap<string, string> }): DecisaoNaFalha {
189  if (classificarOrigem(f.origem, f.agentId) !== 'outra-sessao') return { acao: 'passa' }
190  if (f.papel !== null && f.papel !== 'fase') return { acao: 'passa' }
191  const conferida = typeof f.texto === 'string' ? f.emVoo.get(f.texto) : undefined
192  return conferida !== undefined ? { acao: 'conferida', texto: conferida } : { acao: 'consome' }
193}
194
195/** Um valor de atributo do envelope do motor: uma linha so, sem aspas nem sinal de tag (o mesmo do nucleo). */
196const VALOR_DE_LINHA = '[^"<>\\n\\r\\v\\f\\u0085\\u2028\\u2029]{1,200}'
197/** O envelope do Claude Code na forma que o motor escreve, o mesmo que o nucleo aceita (`entrega-por-mod.ts`). */
198const ENVELOPE_DO_MOTOR = new RegExp('^<cross-session-message' +
199  '(?: from="[^"<>\\s]{1,300}")?' +
200  '(?: from-session="[A-Za-z0-9_-]{1,80}")?' +
201  '(?: hop-chain="[0-9a-f]{24}(?:,[0-9a-f]{24}){0,31}")?' +
202  `(?: from-name="${VALOR_DE_LINHA}")?` +
203  '(?: from-mode="[A-Za-z-]{1,40}")?' +
204  `(?: from-plugin="${VALOR_DE_LINHA}")?` +
205  '>\\n([\\s\\S]*)\\n</cross-session-message>$')
206
207/** A mensagem sem o envelope do motor, como a linha da conversa a mostra; o texto como veio quando nao ha envelope. */
208export function mensagemSemEnvelope(texto: string): string | null {
209  const t = texto.replace(/\r\n?/g, '\n')
210  if (!t.startsWith('<')) return t
211  const m = ENVELOPE_DO_MOTOR.exec(t)
212  return m ? m[1] : null
213}
214
215/**
216 * A impressao do texto de uma mensagem (FNV-1a de 32 bits do texto normalizado, com o tamanho): so diz se a linha da
217 * conversa e a mensagem que o nucleo confirmou, sem guardar o texto (D12). Nao e prova: o que prova e a conferencia.
218 */
219export function impressaoDoTexto(texto: string): string {
220  const t = texto.replace(/\r\n?/g, '\n').replace(/\s+$/, '')
221  let h = 0x811c9dc5
222  for (let i = 0; i < t.length; i++) {
223    h ^= t.charCodeAt(i)
224    h = Math.imul(h, 0x01000193) >>> 0
225  }
226  return `${t.length.toString(16)}-${h.toString(16).padStart(8, '0')}`
227}
228
229/** Guarda o que chegou, o mais novo por ultimo e com teto, sem o texto da mensagem. */
230export function comRecebida(fase: MensageiroFase, r: MensageiroRecebida): MensageiroFase {
231  return { ...fase, recebidas: [...fase.recebidas, r].slice(-MAXIMO_DE_RECEBIDAS) }
232}
233
234/** Muda a recebida de uma entrega confirmada (pelo `envioId`); sem ela, a fase fica como estava. */
235export function mudarRecebida(fase: MensageiroFase, envioId: string, estado: MensageiroEstadoDaRecebida, motivo: string | null, em: number): MensageiroFase {
236  if (!fase.recebidas.some(r => r.envioId === envioId && r.estado !== 'consumida')) return fase
237  return { ...fase, recebidas: fase.recebidas.map(r => (r.envioId === envioId && r.estado !== 'consumida' ? { ...r, estado, motivo, em } : r)) }
238}
239
240/** As entregas que o nucleo confirmou nesta sessao, com a impressao do texto: so a linha de uma delas ganha o rotulo. */
241export function confirmadasDaFase(fase: MensageiroFase | null | undefined): Map<string, string> {
242  return new Map((fase?.recebidas ?? []).flatMap(r =>
243    (r.envioId && r.impressao && r.estado !== 'consumida' && r.estado !== 'nao-entrou' ? [[r.envioId, r.impressao] as [string, string]] : [])))
244}
245
246/**
247 * O texto da linha da mensagem com o rotulo no lugar do cabecalho, so para a entrega que o nucleo confirmou nesta
248 * sessao e com o mesmo texto (a impressao); `null` deixa a linha como o Claude Code a desenha. So visual: o que fica
249 * guardado e o que o modelo le nao mudam.
250 */
251export function textoComRotulo(texto: unknown, confirmadas: ReadonlyMap<string, string>): string | null {
252  const { pedidoId, envioId } = idsDaMarca(texto)
253  if (typeof texto !== 'string' || !pedidoId || !envioId || confirmadas.get(envioId) !== impressaoDoTexto(texto)) return null
254  const cabecalho = `${cabecalhoDaEntrega(pedidoId, envioId)}\n`
255  return texto.startsWith(cabecalho) ? `${rotuloDaEntrega(pedidoId)}\n${texto.slice(cabecalho.length)}` : null
256}
257
types/index.d.ts 86 lines
1// Contrato do estado do Mensageiro de Sessoes (`ork-mensageiro`, RM-059, fatia F9). O motor exige que cada chave de
2// `$.state` do mod esteja declarada aqui, em `PluginState`. O Mensageiro nao acrescenta noun ao `$`: le o nucleo pelo
3// `$.ork` da `ork-ponte`, que o plugin.json lista em `dependencies`, e pelas duas ferramentas MCP da entrega. Nenhum
4// campo guarda o texto da resposta do dono (D12 da thread ork-rm059mensag): so ids, estado, motivo e instantes.
5// Sem import nem referencia.
6
7/**
8 * Onde uma entrega esta, vista da sessao do dono que a leva. `sem-recibo`: a sessao de fase reservou e nao gravou o
9 * recibo em 5 min (D21).
10 */
11export type MensageiroEstadoDaEntrega =
12  | 'na-fila' | 'a-caminho' | 'reservada' | 'sem-recibo' | 'nao-chegou' | 'desistiu' | 'entregue' | 'saiu-da-caixa'
13
14/** Uma entrega da caixa de saida desta sessao do dono, sem o texto. */
15export type MensageiroEntrega = {
16  thread: string
17  pedidoId: string
18  envioId: string
19  sessionId: string
20  estado: MensageiroEstadoDaEntrega
21  /** Quantas vezes a mensagem foi enviada (`$.session.send`), pela conta do nucleo. */
22  tentativas: number
23  /** Por que o ultimo envio nao entrou na fila da sessao, ou por que o Mensageiro parou de tentar. */
24  motivo: string | null
25  /** Quando a entrega mudou de estado aqui (ms desde a epoca). */
26  em: number
27}
28
29/** Um pedido que o retrato mostra com a entrega incerta e que a caixa desta sessao nao tem: segue noutra sessao. */
30export type MensageiroIncerta = { thread: string; pedidoId: string }
31
32/** O lado do dono: as entregas desta sessao e as incertas de fora, com a ultima leitura. */
33export type MensageiroDono = {
34  entregas: MensageiroEntrega[]
35  incertas: MensageiroIncerta[]
36  /** Quando a caixa foi lida (ms desde a epoca); null antes da primeira leitura. */
37  lidoEm: number | null
38  /** Por que a ultima leitura falhou, quando falhou (a anterior fica no painel). */
39  erro: string | null
40}
41
42/**
43 * O que aconteceu, na sessao de fase, com uma mensagem que chegou de outra sessao: consumida antes do modelo (sem
44 * entrega confirmada pelo nucleo), confirmando (conferida, na conversa, gravando o recibo), entregue (com o recibo),
45 * sem recibo (na conversa, mas o recibo nao foi gravado) ou nao entrou (conferida, e outro plugin a consumiu abaixo).
46 */
47export type MensageiroEstadoDaRecebida = 'consumida' | 'confirmando' | 'entregue' | 'sem-recibo' | 'nao-entrou'
48
49/** Uma mensagem de outra sessao que chegou a sessao de fase, sem o texto dela. */
50export type MensageiroRecebida = {
51  /** Quando chegou ou mudou de estado (ms desde a epoca). */
52  em: number
53  /** O tipo da origem e o plugin que o remetente diz ser, numa linha. */
54  origem: string
55  estado: MensageiroEstadoDaRecebida
56  /** O pedido e a entrega: conferidos pelo nucleo na entrega confirmada; na consumida, so o que a marca dizia. */
57  pedidoId: string | null
58  envioId: string | null
59  /** Por que foi consumida ou ficou sem recibo, com o codigo do nucleo. */
60  motivo: string | null
61  /** A impressao do texto da entrega confirmada (FNV-1a e tamanho), para o rotulo; nunca o texto. */
62  impressao: string | null
63}
64
65/** O lado da sessao de fase: a thread do despacho e o que chegou de outras sessoes. */
66export type MensageiroFase = {
67  thread: string | null
68  recebidas: MensageiroRecebida[]
69  /** Por que um trabalho em segundo plano parou, quando parou. */
70  erro: string | null
71}
72
73/** O que o painel mostrou ao dono e o Telegram tambem diria: a chave junta o que e a mesma coisa (M5). */
74export type MensageiroMostrado = { chave: string; thread: string | null; texto: string }
75
76declare module 'claude-code' {
77  interface PluginState {
78    'ork-mensageiro': {
79      /** O lado do dono: as entregas a caminho das sessoes de fase. */
80      dono: MensageiroDono
81      /** O lado da sessao de fase: o que chegou de outras sessoes e o que o nucleo confirmou. */
82      fase: MensageiroFase
83    }
84  }
85}
86