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…

English · Português

In one sentence:
orkconducts 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.
@orkastery/cli, with changes per version · reviewed on 2026-09-27main at PR #74, run 37102089623, 2026-10-03)docs/produto/ and docs/roadmap/, checked against the code by ork docs verificardocs/ 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 printsnpm 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
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
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.ork claims add <thread> <file> --claim "<statement>" --verificar "<command>".ork takes that seat. It is a deterministic TypeScript CLI, with no server and no embedded LLM.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.Without ork | With ork |
|---|---|
| The agent says the tests pass | ork 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 day | An hourly summary says what is pending, urgent, blocking or critical |
| Every obvious decision becomes a question | The obvious one comes decided and reported; you change it if you want |
| The runtime account runs out and the roadmap stops | The same prompt continues on the next profile or on the fallback runtime |
| Two sessions step on the same file | One worktree per thread, and leases with a queue |
| The docs lie about the code | ork docs verificar fails the PR when a page drifts |
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
flowchart LR
G(["GOAL"]) --> P(["PLAN"]) --> GO(["GO"]) --> C(["CHECK"]) --> S(["SHIP"]) --> M(["MASTER"])
C -.->|"failed: GO-FIX with a typed spec"| GO
| Phase | What it delivers | What ork requires to pass |
|---|---|---|
| GOAL | Objective, done criteria, claims with a command | a claim without a command is refused (claims.unverifiable) |
| PLAN | Tasks, decisions, an executable verify per task | a plan without an executable verify does not become GO |
| GO | One atomic commit per task, in the thread's worktree | writing outside the worktree is blocked by a lease |
| CHECK | Verification against the baseline, plus independent CI | a failed claim becomes claims.failed or verify.regression |
| SHIP | Serialized merge with a proven push | only counts when git ls-remote matches the local SHA |
| MASTER | Typed postmortem and a conduction index | the index comes from the ledger; nobody types it |
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.
| #TAG | Pauses | Cycle | What it is for |
|---|---|---|---|
| #Classic | 3 | GOAL* / PLAN* / GO-CHECK* / SHIP-MASTER | The ork init default: delicate premises |
| #Maestro | 1 | GOAL-PLAN* / GO-CHECK-SHIP / MASTER | A clear, quick solution |
| #Auto | 0 | GOAL-PLAN-GO-CHECK-SHIP-MASTER | Docs, studies, configuration, audits |
| #Fast | 0 | GO | A 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.
claims.jsonl. There are even negative claims, checked by the command that would falsify them.ork verify --baseline records the world before GO.ork ci prepare exports the claims, and the GitHub ork-verify job re-runs them on the exact SHA.git ls-remote, never by the exit code of git push.ork retry policy. cost.violation never gets an automatic retry.ork decisao registrar keeps the why, how to change it and the cost of changing it now or later; you are informed.owner.timezone, stated once per message.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.ork accounts add <id> --runtime R --dir D runs the CLI's own login; ork never reads, copies or migrates a credential.cost.violation. Details and responsibilities in SECURITY.md.memory: orkmind, memory goes to the tenant's database; without it, it degrades to files with a typed reason.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.documentacao job runs markdownlint and ork docs verificar on every PR.ork adapter list # the hosts and where each one installs
ork adapter install claude-code --dry-run
| Host | What gets installed |
|---|---|
| claude-code | A plugin with the catalog skills, phase subagents, the /orkastery:ork entry point and hooks |
| codex | The $ork entry point and the conduction catalog as project-local skills |
| hermes | A routing skill, the HITL ingress plugin and thread-opening scripts |
| openclaw | An extension with the ork_* tools, each one a CLI call |
ork modos --do-pedido; the core validates it.| Capability | Status |
|---|---|
| Threads, six phases, four modes, ledger, worktrees, leases, board | works |
Claims, baseline, verify on the real HEAD, 22 typed reasons, GO-FIX | works |
| CI as an independent CHECK on the exact SHA | works; native protection of main depends on the GitHub plan (RM-012) |
ork ship with a proven push | works |
| Account rotation across profiles and runtimes | works; account state is still per project (RM-040) |
| Layered HITL, a–d batch, decisions made and reported | works since 2026-09-24 (RM-041); automatic hourly summary since 2026-09-25 (RM-045) |
| Documentation as code with parity in CI | works (RM-044) |
#Fast mode: one phase, minimal proof, no push on its own | works since 2026-09-27 (RM-042) |
| Roadmap item reservations across machines | works since 2026-09-27 (RM-047); thread state is still per machine |
| Stable local verify on a machine whose CPU the hypervisor steals | does not exist: on such a machine, the reliable proof is CI (RM-037) |
A runtime_reported measurement of the context window | does not exist: claude-bg does not expose context usage, and the gate says unavailable |
| Deterministic code graph | works 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.
| Measure | Value | How to check |
|---|---|---|
| Core tests | 2,733, zero failing (CI on 2026-10-03) | npm --prefix core run test:ci |
| Behavior canaries | 24, all green | ork eval --so-canarios |
| Skills corpus | 20 skills, 101 cases, 199 assertions | ork eval --so-skills |
| Product docs and roadmap | zero parity errors | ork docs verificar |
| Runtime dependencies | 9, pinned | core/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.
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.
| Section | Start with |
|---|---|
| Getting started | Quickstart in English, and the full quickstart in Portuguese: from zero to the first full cycle |
| Guides | Modes, verification, human attention, memory, audits |
| Reference | CLI (the source is ork --help) and contracts |
| Concepts | Overview and architecture |
| Product and roadmap | What exists and what comes next, checked against the code |
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 prints | It means |
|---|---|
PRONTO / BLOQUEADO | ready / blocked: the ork doctor verdict |
correcao | the fix for the line above it |
Proximo passo | next step |
VERDADE SUSTENTADA / REPROVADO | the claims held / failed: the ork verify verdict |
AGUARDANDO AUTORIZACAO HUMANA | waiting for your authorization |
Precisa de humano agora | needs a human now: the first line of ork pulse |
Recomendação, opção | recommendation, option |
aceitas por omissao | accepted by default, because nobody scored them |
pendente, respondida | pending, answered |
Horários em <zone> | times are shown in that time zone |
03/10 06:30 is October 3. The commands that follow an English locale print 2026-10-03 06:30.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.| Term | Meaning |
|---|---|
| HITL | human in the loop: a question only you can answer, with alternatives and one recommendation |
| ledger | the append-only event log of a thread, in .orkastery/threads/<thread>/ledger.jsonl; ork phase list <thread> prints it |
| claim | a statement, the file it is about and the command that judges it |
| worktree | the separate git checkout of each thread, under .claude/worktrees/ |
| lease | a lock on a shared resource, such as the main tree during a merge, with a queue |
| pulse | the hourly summary of what waits for you: ork pulse |
| maestro | the conversational overview in a host (say orkastery maestro), and the onboarding stage about you |
| GOAL … MASTER | the six phases, in the thread table |
| #Classic, #Maestro, #Auto, #Fast | the conduction modes, in the modes table |
ork docs sincronizar.ork roadmap pegar), and two never take the same one.| Project | What it is |
|---|---|
| orkastery | This repository: the ork core, the adapters, the skills and the auditors |
| OrkMind | The memory layer: Postgres with pgvector, an ontology and tag search; optional |
| orkastery.com | The website and user docs |
| orkmind.com | The OrkMind site |
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.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.
hooks/register.tsx 482 lines1// 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}
482hooks/caixa.ts 216 lines1// 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}
216hooks/painel.tsx 166 lines1// 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}
166hooks/recepcao.ts 257 lines1// 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}
257types/index.d.ts 86 lines1// 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