diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 6331c1af32..fd7487daf6 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,7 @@ { "name": "doperpowers", "description": "Emerges by humans, Converges by Agent", - "version": "7.176.0", + "version": "7.177.0", "source": "./", "author": { "name": "SSFSKIM", diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 92e4f18d04..53b9fafc51 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "doperpowers", "description": "Emerges by humans, Converges by Agents", - "version": "7.176.0", + "version": "7.177.0", "author": { "name": "SSFSKIM", "email": "supremekim17@gmail.com" diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 90ec05e9ad..157aefaa8a 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "doperpowers", - "version": "7.176.0", + "version": "7.177.0", "description": "One software-development methodology for coding agents, sized by the work: brainstorm, design, spec, TDD, review, ship — one spec that carries its plan of work as milestones: one milestone runs in order from the spec, several run a fresh subagent per milestone reviewed at each dependency frontier, and the same method runs unattended on the board loop.", "author": { "name": "SSFSKIM", diff --git a/CLAUDE.md b/CLAUDE.md index 420f42fbae..d72193d75d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -80,7 +80,7 @@ CLI adapter, Codex portal packaging — are automatically irrelevant. | `agents/` | Registered agents, addressed as `doperpowers:`: `critique` (design critic, debated over SendMessage); `reviewer-low` / `reviewer-medium` / `reviewer-high` (one rubric body, pinned to sol/high, sol/xhigh, astra/high through the local gateway — the single-reviewer rungs of `review-code`, dispatchable from any session or subagent; every reviewer agent, and `adversarial-reviewer`, blocks `Agent` and `Skill` so a reviewer cannot route back into `review-code` and start a review inside its own) and `adversarial-reviewer` (its spec/plan challenge reviewer); `qa-loop` (opus/high) is the board's review loop — the seat that owns the ticket (an Architect or an Executor) dispatches it on the PR it opened and stays bound through review, and it owns the engine rounds, the compliance audit, triage, fix waves, the merge, and the trail, returning only a spec conflict, a design gap, or a dismissal request; it blocks `Skill` so no repository instruction can route it into a review skill, and keeps `Agent` for the reviewers and fixers it dispatches (a PR nobody owns draws a review stand-in seat, which dispatches the same agent); `plan-executor` (opus/high) executes a spec's Plan of Work — or a ticket body — for the session that authored it, messaging that session at the checkpoints it judges meaningful and returning at completion or a genuine block — SDE controller when the Plan of Work has several milestones, sequential for one; dispatched by the board Architect past its build edge, and by execspec's step 4 in an interactive session (a one-milestone spec in an interactive session is run inline by its author); `task-executor` (opus/high) / `task-reviewer` (sol/xhigh) are subagent-driven-execution's per-milestone workers — the invariant worker contracts live here so a controller's dispatch carries only the task's paths and context (the reviewer is read-only and can neither spawn agents nor load skills; the executor keeps `Agent` for orientation fan-out, its body rules out spawning a reviewer, and it may be downgraded to sonnet per dispatch). Both executors run detached from the human: `AskUserQuestion` is disallowed in their definitions, and whatever needs an input only the human or the work's owners can give — environmental friction, an approval, a fork — is escalated up the dispatch chain (the task-executor's return to its controller, the plan-executor's checkpoint message or escalation return to its dispatching session) until the main session puts it to the human and relays the answer down. The three pins — plan-executor and task-executor opus/high, task-reviewer sol/xhigh — change only on an explicit instruction, by edit or by dispatch override, whatever model wrote the spec (usually the frontier one): the frontier's work is the design and the spec; running the plan and building its milestones is close-frontier work. The panel workflow reaches the reviewers by `agentType`, overriding model and effort per rung. | | `skills/` | The core product — one dir per skill, each with `SKILL.md` (frontmatter `name` + `description` that drives auto-trigger). 15 skills. `brainstorming` closes and presents the design; `execspec` writes the living spec from the approved design, reviews it, and executes it (its `references/living-spec.md` is the document's doctrine, read by SDE, decomposing, and the board protocols). Worker protocols for the board loop (architect / implement / spike / the review stand-in) are plain files under `issue-tracker/references/`, deliberately not skills — nothing invokes them by name; the dispatcher pins their path into the spawn bootstrap. The review loop lives there too: `references/review-loop.md` is its operator manual, `review-standin-protocol.md` and `review-standin-bootstrap.md` the stand-in's, and `scripts/review-dispatch.sh` the trigger for the reviews nobody owns. `sminos` is the project family: a committed `.smns` names a project, every session working under it (spawned seat or one the human opened) is a member with no join step, and what crosses sessions — `say` to peers (untagged is a record, `@alias` delivers, `@all` wakes every live member), a member's `` to the human, joining, an in-charge line, spawning a sibling, retiring — is one event in the project's append-only log, one file per writer under `$SMINOS_HOME/projects//log/`. With a remote in `.smns` the log is a git working tree mirrored through it, and `sminos daemon` (one per machine; `daemon install` writes a launchd agent on macOS, a systemd user unit on Linux) syncs the mirror, delivers remote messages to local members, and publishes their state, so a project spans machines. A project whose `.smns` names a desk hub instead (`project init --hub`, on a machine enrolled by `sminos hub enroll`, which reads the enrollment code — it carries the org key — from a hidden prompt or stdin and refuses it as an argument; `hub show` prints the machine's identity, never a secret) syncs through the hub alone, never through git: `daemon run` supervises `daemon uplink`, a child process holding the hub's stream, which uploads the machine's own writers sealed with authentication codes and verifies every event the hub sends before it enters the log, and the machine writes under its enrolled name. Over the same stream the uplink serves the desk's session plane and its RPCs, the daemon being the execution boundary: presence from the engine's session records, each subscribed session's transcript and its bridge's stream joined in one order, the bridges' pending requests and their settlements, and the outcome of each delivery of a message from outside the machine; an RPC (steer, interrupt, stop, answer, decide, model, compact, spawn, retire, resume) runs only once its code, the machine, project, and session it names, the at-most-once ledger (`daemon/rpcs.json`), its age, the machine's policy, and, for an answer or a decision, the digest of the request still pending all hold, and its result is sent again until the hub acknowledges it. The policy (`daemon/policy.json`, set by `sminos daemon policy [--read-only on|off] [--ceiling MODE|settings]`) can make the machine read-only — no RPC runs, nothing from outside is delivered — and caps a spawn's permission mode; Resume runs `claude --bg --resume ` with no other flag once no process of the machine holds the session, and stops any copy the engine starts instead. Such a project's remote keeps only the log's archive, which the one machine the hub designates for it writes on a schedule (`SMINOS_ARCHIVE_EVERY`, 15 minutes) from a clone at `projects//archive/`, never read back into the log. The CLI is `scripts/sminos.py` with `sminos_daemon.py` (mirror and delivery), `sminos_hub.py` (enrollment), `sminos_uplink.py` and `sminos_hublog.py` (the hub's stream, and one project's log plane over it), `sminos_plane.py` (the session plane: presence and subscriptions, each transcript's offset index and its worker thread, so a subscription pages a session's whole history — an ended one's too — off the uplink's thread), `sminos_jobs.py` (the subagents' status, background jobs and MCP names derived from the rows, and `output`'s containment; pinned with MAWS's fake machine by `tests/sminos/dx4-jobs.json`; plan `docs/doperpowers/plans/2026-10-17-desk-dc8-machine.md`), `sminos_requests.py` and `sminos_outcomes.py` (pending requests and their settlements, and delivery outcomes, as the hub's sealed messages), `sminos_policy.py` (the machine's policy and its known checkouts), `sminos_rpc.py` (the RPCs: their checks, the ledger, execution; plan `docs/doperpowers/plans/2026-10-15-desk-dc7-controls.md`), `sminos_codes.py` (the codes, pinned to the hub's vectors by `tests/sminos/codes_parity.py`), `sminos_archive.py` (the archive), `sminos_marks.py` (the to-human mark grammar, ported from `to-human/hooks/mods/to-human.tsx` and pinned by `tests/sminos/marks_parity.py`), `sminos_bridge.py` (the child the bridge mod spawns, serving the session's endpoint at `$SMINOS_HOME/bridge/.sock`, and `sminos watch`), `sminos_control.py` (`steer`, `interrupt`, `stop`, `model`, `compact`, `decide`, `answer`, and a delivery's last hop to a live session: the bridge, else a `--bg` job's supervisor `reply` for a human's message, else the inbox frame), and the chart and TUI; a session the bridge mod runs in is watched and driven from any terminal on the machine with `sminos watch|steer|interrupt|stop|model|compact|decide|answer `, its protocol DX5 in `docs/doperpowers/plans/2026-10-15-arkho-desk-c2-bridge.md`; tests `tests/sminos/run-sminos-tests.sh`, which isolate `HOME` and `SMINOS_HOME` — a working copy's CLI run against the real `~/.claude/sminos` performs the one-way import of the old per-host `chats/` on its first call. Design in `docs/doperpowers/specs/2026-10-13-smns-project-log-design.md`; the hub side in `docs/doperpowers/plans/2026-10-15-desk-dc4-uplink.md`, a child of the desk's composite design in MAWS. | | `application-agents/` | Deployed agent applications that are not skills — nothing invokes them by name from a session. `triaging-feedback/`: the feedback→triage poller (TypeScript, its own `npm test`), its worker protocol, and setup docs; the worker-host under `infra/` runs it. | -| `hooks/` | The `kairos` proactive mode: `kairos.sh` on `SessionStart` injects the `kairos` skill's body at startup and after every compaction for a session in the mode — launched with `KAIROS=1`, or switched on mid-session by typing `/kairos`, which `kairos-toggle.sh` (on `UserPromptExpansion`) records as a flag file under `~/.claude/kairos/`; `/kairos off` removes it. Every other session gets nothing. `smns-record.sh` on `Stop` and `SessionStart` passes the payload to `sminos record`, which joins the session to its project and exports what it sent its human since the last export (its `` spans, code-aware, and its async asks and their answers) into the project's log; it is bounded (5 s, `SMNS_RECORD_TIMEOUT`), exits 0 on any failure, and does nothing outside a project or in sminos's own internal forks (`SMNS_INTERNAL`). `mods/` holds the plugin's function hooks ("mods"): `hooks.json` names `mods/register.tsx` under `modules`, which the engine loads only under `CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1` and ignores elsewhere. `to-human-stream.sh` on `MessageDisplay` draws the `` mark's labels flush by flush while a message streams (the engine mounts an `AssistantMessage` only once a block is whole); the report view that folds the transcript to those marks is the `to-human` plugin (`to-human/`), whose parser reads either that form or the raw tags; `kairos.tsx` draws a `[ kairos off ]` / `[ kairos on ]` button in the prompt footer that runs `/kairos` or `/kairos off` on a click, reading the mode's state from the shell hooks and never writing it; `agents.tsx` adds an `[ N subagents ]` button beside the kairos switch (registered ahead of it, wrapping its tree) from the session's first subagent on, which opens a pane drawing the session's agents as the tree they spawned in (`$.agent.list()`'s `parentId`), and a press on a row draws that agent's transcript file beneath the tree, following its tail while it runs. `ask.tsx` registers `mcp__doperpowers__AskUserQuestion`, the model's one way to ask the human in an interactive session: the built-in's input plus `async`. Blocking, it opens the engine's own dialog (a `$.ui.ask` raised inside the tool's hook and widened to the full question set by the mod's own `tool.call` hook on `AskUserQuestion`, correlated by a token in the header) and returns the engine's text; async, it returns at once, the questions wait behind `[ answer ]` in the call's row and in an `N open questions` band above the prompt, and the answer reaches the model as `context` on its next tool result, or as a plugin prompt when the session is idle, or with the human's next prompt after a stop. The built-in is deferred and refused while the tool exists; design in `docs/doperpowers/specs/2026-10-13-ask-async-design.md`. `compact.tsx` registers two tools the model calls, `mcp__doperpowers__context_usage` (the status line's context figures and where the engine compacts on its own, at no cost) and `mcp__doperpowers__compact` (the model compacting its own context at a checkpoint of its choosing): the engine compacts only between turns, so the tool answers that the compaction runs when the turn ends and the model ends its turn; at the `turn.complete` of a main-conversation turn the model answered the mod calls `$.session.compact` (the `/compact` path: one model call over the live conversation, so the model that chose to compact writes the summary, the PreCompact hook's instructions merged in) and submits the `resume` prompt the model wrote, whatever the outcome, so an unwatched session never stalls; nothing is aborted, so background shells, monitors, MCP servers and subagents run on (a `$.turn.abort` variant was rejected: it kills Monitors, whose shell runner gets no `caller` and so does not spare `turn-abort`). Interactive sessions only (headless has no compactor a plugin can call); a subagent's call to either is refused (`context_usage` would read the parent's figures). The engine hands every subagent every MCP tool and a hook cannot filter per agent, so the agent definitions this repo owns, and the user's own under `~/.claude/agents/`, withhold both through `disallowedTools`; built-in types (general-purpose, Explore) and forks still list them. The engine admits one unmatched registration of an event per module, and `$` may only be passed to functions in the same file, so the mod takes `session.start` and `turn.complete` with matchers (`isInteractive: true`, `reason: "answer"`) rather than sharing kairos's and agents' registrations. `interim.ts` drops, at `prompt.submit`, an agent's task notification the engine marks interim (it stopped with a background shell, monitor or workflow of its own still running) when its whole result is the engine's waiting placeholder, the agent's transcript shows the hand-back contract on (so the engine wrote that sentence, not the agent; reached through the notification's own `` link, so the hook keeps no session state, and searched with `grep`, past `$.fs.read`'s 4 MiB), and it tells the session nothing its conversation does not hold (the session's own loop spawned the agent or the conversation, read as it stands through `$.session.messages({ as: "api" })`, holds an earlier notice of it, and of any worktree it names), so a subagent parked on its own work no longer wakes the session for a turn with nothing to act on; a pointer to a delivered report passes (it can name a message that went to an ended parent), as do the first notice of each worktree and of each orphan, the final notification, and whatever a check errs on or cannot settle. Each drop still leaves the engine's "Prompt dropped by a hook" line, a system notice no render hook reaches. `appeal.tsx` (with `appeal-judge.tsx`, the logic that takes no `$`) lets the agent contest an auto-mode classifier block: a `tool.call` hook on every tool gives each appealable block an id and a note (not `[Data Exfiltration]`, not `[User Deny Rules]`), `mcp__doperpowers__appeal` puts one appeal per block to an independent judge (`$.model.complete`, opus at high effort, reading the blocked call whole, the argument as the agent's claim, the instruction files, the `user`/`policy`/`flag` settings' `autoMode` and `permissions.deny` — never the merged settings a repository feeds — the answered async asks, and the conversations with provenance-stripped rows named), and a grant leaves a one-shot pass that the `tool.check` hook applies to the identical re-run in the same loop within 10 minutes, only over core's plain `ask` (no rule, hook, setting, decider or ceiling); any `prompt.submit` ends every pass; the log is `~/.claude/appeals/.jsonl` and `/appeals` prints the last 30 days with the autoMode entries the grants suggest, for the human to apply (design `docs/doperpowers/specs/2026-10-17-auto-mode-appeal-design.md`). `bridge.tsx` (registered first, so its hooks are the outermost) makes the session reachable from outside it on this machine: at `session.start` it spawns a session-lifetime child, `sminos bridge --session `, which serves the session's endpoint at `$SMINOS_HOME/bridge/.sock` (the socket answering is the session's bridge presence); the mod posts the session's events there from one poster loop (each chunk of the main loop and of every subagent, tool calls and results, turns, subagents, usage, a heartbeat, and a notice for every row the session keeps, as it is written, each loop's last notice the snapshot's cut, seeded, for a loop older than the module, from its transcript tail at the module's first hello; every post carries the hello's nonce, so a reloaded module's old instance is refused and stops) and reads commands from the child's stdout: steer (a user row the loop reads at its next request), interrupt (the turn aborted, each Bash the abort moved to the background stopped, and two user rows in before the turn can end: Esc's marker whole, the only text a transcript's reader knows an interrupt by, then a note telling the model where it came from and what was stopped), model and effort (rewritten on the main loop's requests, never `$.config.set`), compact (at the turn's end when asked during one), a log event delivered as the human's words once per `(writer, seq)`, and an answer from outside to a permission prompt or a question, settled once on the engine's own `tool_decision` record of who decided. It exports the main loop's `` spans and async questions to the project's log as their rows are written, through the record path's own writer and `(kind, src)` keys, so the Stop hook's export finds them there. Never in `SMNS_INTERNAL`. `bridge.tsx` keeps the hooks and the code that takes `$` (the session start, the child, control, the wait for a decision); its other concerns sit beside it, none taking `$`, each importing only the ones listed before it: `bridge-state.tsx` (the one state object, its types, `resetBridge`), `bridge-stream.tsx` (the live state every resync carries, and the poster), `bridge-requests.tsx` (requests from outside and their settlement), `bridge-rows.tsx` (each kept row's notice, and the export to the host), `bridge-agents.tsx` (the agent state seeded and refreshed from `$.agent.list()`, so agents that predate the module are known). The protocol is DX5 in `docs/doperpowers/plans/2026-10-15-arkho-desk-c2-bridge.md`; tests `tests/mods/bridge.test.ts` and `tests/sminos/test-bridge*.sh`, live checks `tests/mods/live-*-check.py` (they spend model calls; run by hand). `/secret KEY` ships in the `cua` plugin (SSFSKIM/cua, `hooks/mods/secrets.tsx`), same store `~/.config/claude-secrets/KEY`; the guard that keeps secrets out of what the model reads and the transcript keeps is the devcloud plugin's (`devcloud/`). Tests in `tests/mods/`, run with `tests/mods/run-mods-tests.sh`; the streaming hook's are `tests/claude-code/test-to-human-stream-hook.sh`. `compact-instructions.md` is printed by a `PreCompact` hook on every compaction, auto or `/compact`: the harness appends a PreCompact hook's stdout to the summary request as "Additional Instructions" (confirmed in the 2.1.283 source and by a canary hook), after the stock nine-section template that tells the summarizer to follow its structure — text that does not say it overrides the template is read as a hint and barely moves the summary (2026-09-28: the first wording produced summaries indistinguishable from no instructions), so each summary is written as the note a memory-reset self resumes from — pointers for what is on disk, copies of what lived only in the conversation (the summarizer can call no tools, and the transcript path is appended after the summary). Subagent compactions do not receive it. The upstream `SessionStart` bootstrap that used to live here (injecting the `using-doperpowers` skill index) was torn out; skills load from `skills/` directly. | +| `hooks/` | The `kairos` proactive mode: `kairos.sh` on `SessionStart` injects the `kairos` skill's body at startup and after every compaction for a session in the mode — launched with `KAIROS=1`, or switched on mid-session by typing `/kairos`, which `kairos-toggle.sh` (on `UserPromptExpansion`) records as a flag file under `~/.claude/kairos/`; `/kairos off` removes it. Every other session gets nothing. `smns-record.sh` on `Stop` and `SessionStart` passes the payload to `sminos record`, which joins the session to its project and exports what it sent its human since the last export (its `` spans, code-aware, and its async asks and their answers) into the project's log; it is bounded (5 s, `SMNS_RECORD_TIMEOUT`), exits 0 on any failure, and does nothing outside a project or in sminos's own internal forks (`SMNS_INTERNAL`). `mods/` holds the plugin's function hooks ("mods"): `hooks.json` names `mods/register.tsx` under `modules`, which the engine loads only under `CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1` and ignores elsewhere. `to-human-stream.sh` on `MessageDisplay` draws the `` mark's labels flush by flush while a message streams (the engine mounts an `AssistantMessage` only once a block is whole); the report view that folds the transcript to those marks is the `to-human` plugin (`to-human/`), whose parser reads either that form or the raw tags; `kairos.tsx` draws a `[ kairos off ]` / `[ kairos on ]` button in the prompt footer that runs `/kairos` or `/kairos off` on a click, reading the mode's state from the shell hooks and never writing it; `agents.tsx` adds an `[ N subagents ]` button beside the kairos switch (registered ahead of it, wrapping its tree) from the session's first subagent on, which opens a pane drawing the session's agents as the tree they spawned in (`$.agent.list()`'s `parentId`), and a press on a row draws that agent's transcript file beneath the tree, following its tail while it runs. `ask.tsx` registers `mcp__doperpowers__AskUserQuestion`, the model's one way to ask the human in an interactive session: the built-in's input plus `async`. Blocking, it opens the engine's own dialog (a `$.ui.ask` raised inside the tool's hook and widened to the full question set by the mod's own `tool.call` hook on `AskUserQuestion`, correlated by a token in the header) and returns the engine's text; async, it returns at once, the questions wait behind `[ answer ]` in the call's row and in an `N open questions` band above the prompt, and the answer reaches the model as `context` on its next tool result, or as a plugin prompt when the session is idle, or with the human's next prompt after a stop. The built-in is deferred and refused while the tool exists; design in `docs/doperpowers/specs/2026-10-13-ask-async-design.md`. `compact.tsx` registers two tools the model calls, `mcp__doperpowers__context_usage` (the status line's context figures and where the engine compacts on its own, at no cost) and `mcp__doperpowers__compact` (the model compacting its own context at a checkpoint of its choosing): the engine compacts only between turns, so the tool answers that the compaction runs when the turn ends and the model ends its turn; at the `turn.complete` of a main-conversation turn the model answered the mod calls `$.session.compact` (the `/compact` path: one model call over the live conversation, so the model that chose to compact writes the summary, the PreCompact hook's instructions merged in) and submits the `resume` prompt the model wrote, whatever the outcome, so an unwatched session never stalls; nothing is aborted, so background shells, monitors, MCP servers and subagents run on (a `$.turn.abort` variant was rejected: it kills Monitors, whose shell runner gets no `caller` and so does not spare `turn-abort`). Interactive sessions only (headless has no compactor a plugin can call); a subagent's call to either is refused (`context_usage` would read the parent's figures). The engine hands every subagent every MCP tool and a hook cannot filter per agent, so the agent definitions this repo owns, and the user's own under `~/.claude/agents/`, withhold both through `disallowedTools`; built-in types (general-purpose, Explore) and forks still list them. The engine admits one unmatched registration of an event per module, and `$` may only be passed to functions in the same file, so the mod takes `session.start` and `turn.complete` with matchers (`isInteractive: true`, `reason: "answer"`) rather than sharing kairos's and agents' registrations. `interim.ts` drops, at `prompt.submit`, an agent's task notification the engine marks interim (it stopped with a background shell, monitor or workflow of its own still running) when its whole result is the engine's waiting placeholder, the agent's transcript shows the hand-back contract on (so the engine wrote that sentence, not the agent; reached through the notification's own `` link, so the hook keeps no session state, and searched with `grep`, past `$.fs.read`'s 4 MiB), and it tells the session nothing its conversation does not hold (the session's own loop spawned the agent or the conversation, read as it stands through `$.session.messages({ as: "api" })`, holds an earlier notice of it, and of any worktree it names), so a subagent parked on its own work no longer wakes the session for a turn with nothing to act on; a pointer to a delivered report passes (it can name a message that went to an ended parent), as do the first notice of each worktree and of each orphan, the final notification, and whatever a check errs on or cannot settle. Each drop still leaves the engine's "Prompt dropped by a hook" line, a system notice no render hook reaches. `appeal.tsx` (with `appeal-judge.tsx`, the logic that takes no `$`) lets the agent contest an auto-mode classifier block: a `tool.call` hook on every tool gives each appealable block an id and a note (not `[Data Exfiltration]`, not `[User Deny Rules]`), `mcp__doperpowers__appeal` puts one appeal per block to an independent judge (`$.model.complete`, opus at high effort, reading the blocked call whole, the argument as the agent's claim, the instruction files, the `user`/`policy`/`flag` settings' `autoMode` and `permissions.deny` — never the merged settings a repository feeds — the answered async asks, and the conversations with provenance-stripped rows named), and a grant leaves a one-shot pass that the `tool.check` hook applies to the identical re-run in the same loop within 10 minutes, only over core's plain `ask` (no rule, hook, setting, decider or ceiling); any `prompt.submit` ends every pass; the log is `~/.claude/appeals/.jsonl` and `/appeals` prints the last 30 days with the autoMode entries the grants suggest, for the human to apply (design `docs/doperpowers/specs/2026-10-17-auto-mode-appeal-design.md`). `bridge.tsx` (registered first, so its hooks are the outermost) makes the session reachable from outside it on this machine: at `session.start` it spawns a session-lifetime child, `sminos bridge --session `, which serves the session's endpoint at `$SMINOS_HOME/bridge/.sock` (the socket answering is the session's bridge presence); the mod posts the session's events there from one poster loop (each chunk of the main loop and of every subagent, tool calls and results, turns, subagents, usage, a heartbeat, and a notice for every row the session keeps, as it is written, each loop's last notice the snapshot's cut, seeded, for a loop older than the module, from its transcript tail at the module's first hello; every post carries the hello's nonce, so a reloaded module's old instance is refused and stops) and reads commands from the child's stdout: steer (a user row the loop reads at its next request), interrupt (the turn aborted, each Bash the abort moved to the background stopped, and two user rows in before the turn can end: Esc's marker whole, the only text a transcript's reader knows an interrupt by, then a note telling the model where it came from and what was stopped), model and effort (rewritten on the main loop's requests, never `$.config.set`), compact (at the turn's end when asked during one), an owner's log event (kind `human`, written by `/human` or `hub/`) delivered as the human's words once per `(project, writer, seq)` (a peer's event goes instead to the engine's inbox as a peer message, in the engine's envelope, closed by the reply line), and an answer from outside to a permission prompt or a question, settled once on the engine's own `tool_decision` record of who decided. It exports the main loop's `` spans and async questions to the project's log as their rows are written, through the record path's own writer and `(kind, src)` keys, so the Stop hook's export finds them there. Never in `SMNS_INTERNAL`. `bridge.tsx` keeps the hooks and the code that takes `$` (the session start, the child, control, the wait for a decision); its other concerns sit beside it, none taking `$`, each importing only the ones listed before it: `bridge-state.tsx` (the one state object, its types, `resetBridge`), `bridge-stream.tsx` (the live state every resync carries, and the poster), `bridge-requests.tsx` (requests from outside and their settlement), `bridge-rows.tsx` (each kept row's notice, and the export to the host), `bridge-agents.tsx` (the agent state seeded and refreshed from `$.agent.list()`, so agents that predate the module are known). The protocol is DX5 in `docs/doperpowers/plans/2026-10-15-arkho-desk-c2-bridge.md`; tests `tests/mods/bridge.test.ts` and `tests/sminos/test-bridge*.sh`, live checks `tests/mods/live-*-check.py` (they spend model calls; run by hand). `/secret KEY` ships in the `cua` plugin (SSFSKIM/cua, `hooks/mods/secrets.tsx`), same store `~/.config/claude-secrets/KEY`; the guard that keeps secrets out of what the model reads and the transcript keeps is the devcloud plugin's (`devcloud/`). Tests in `tests/mods/`, run with `tests/mods/run-mods-tests.sh`; the streaming hook's are `tests/claude-code/test-to-human-stream-hook.sh`. `compact-instructions.md` is printed by a `PreCompact` hook on every compaction, auto or `/compact`: the harness appends a PreCompact hook's stdout to the summary request as "Additional Instructions" (confirmed in the 2.1.283 source and by a canary hook), after the stock nine-section template that tells the summarizer to follow its structure — text that does not say it overrides the template is read as a hint and barely moves the summary (2026-09-28: the first wording produced summaries indistinguishable from no instructions), so each summary is written as the note a memory-reset self resumes from — pointers for what is on disk, copies of what lived only in the conversation (the summarizer can call no tools, and the transcript path is appended after the summary). Subagent compactions do not receive it. The upstream `SessionStart` bootstrap that used to live here (injecting the `using-doperpowers` skill index) was torn out; skills load from `skills/` directly. | | `devcloud/` | A second plugin in this marketplace (its entry found by name, versioned on its own: `scripts/bump-version.sh --plugin devcloud`), opt-in, design in `docs/doperpowers/specs/2026-10-15-devcloud-design.md`. Today its secrets guard mod, `hooks/mods/secrets.tsx` (pure parts beside it: `secrets-detect.tsx` the token detector, `secrets-handles.tsx` the handles and their restoration, `secrets-sources.tsx` the store layout and credential-file parsers, `secrets-shell.tsx` when a value may be restored into a Bash command — inert, in a plain string or word, in a plain command, never quoted — and `secrets-reads.tsx` the exact read a notice names where it is not, both proved by `devcloud/tests/mods/shell-exec.ts` under bash and zsh): stored `/secret` values (cua's store, read here), credential-file values and token-shaped strings become handles (`[secret:KEY]`, `[redacted:#.]`, ...) in tool results and their records, every appended row, oversized outputs' `tool-results/` files, the system prompt's sections, context blocks, attachments and compactions; handles in Write/Edit/MultiEdit/NotebookEdit/Bash input are restored to their bytes before the call runs; nothing is refused. It sets its guard marker in `$.state` (`devcloud.secrets-guard`, typed in `types/devcloud.d.ts`) so `cua`'s scrubbing stands down. What it does not reach is listed in `devcloud/README.md`. Tests `devcloud/tests/mods/run.sh` (the `devcloud-mods` gate). The host: `image/` (`setup.sh`, the `Dockerfile` whose one step it is) and `boot/` (`devcloud-boot`, steps 1–12 in `devcloudlib/steps.py`, step 12's projects and mirror cache in `devcloudlib/projects.py`, step 11's sminos daemon on an enrolled persistent host in `devcloudlib/sminos_host.py`, the tailnet join with the Tailscale OAuth client in step 4; `devcloud` for status and the jobs; `devcloud-services`, the supervisor where PID 1 is not systemd), tested in containers by `devcloud/tests/run.sh` (the `devcloud` gate). `skills/devenv/` writes a repository's `devenv/` from its CI, manifests and README and proves it with `devenv up`. | | `to-human/` | A third plugin in this marketplace (versioned on its own: `scripts/bump-version.sh --plugin to-human`), the report view of a session under the `to-human` output style. It is a plugin of its own because the engine runs none of a plugin's render hooks on a row that plugin submitted (the debug log's `ui.render skipped: re-entry (the plugin's own code raised it)`), and doperpowers submits prompts: the bridge's sminos deliveries, the ask's answers, the compact mod's resume prompt. Its `plugin.json` lists doperpowers under `dependencies`, which puts its hooks outside doperpowers' on every event (at equal rank the dependent is outer), so it sees the ask mod's rows before the ask draws them; its band above the prompt draws its line under what the ask's band draws there. `hooks/mods/to-human.tsx` is the consumer of the `to-human` output style on every surface (``, with old ``, ``, `` and `` marks read as aliases of it; tags in inline code or fenced blocks stay literal; an assistant message draws as its marked spans, each body a `Markdown` element on every surface (a render answer to a remote surface carries one props and only the first engine node draws there; on the terminal the engine's block carries its row margin between label and body), and the view's rows stand a line apart as the engine's do; a run of working record — unmarked messages, tool calls and their results — between two rows the human reads folds behind one `[ working record ]` button; a question asked through `AskUserQuestion` or the ask mod's tool stays in the report, and so do agent spawns through `Agent` or `Workflow` and the notification that one failed, and an API error that ends the main turn (sent and received agent messages, the other finish notifications, and a sminos delivery whose frame names a sender other than `human` fold into the working record); buttons unfold a run, a message, or the whole transcript; on the terminal a span's link to a local file, `[name](/abs/path#L42)`, draws behind a file glyph as a `file:` link, and a plain click on it opens VS Code at the line, a cmd-click staying the terminal's). System notices (`Prompt dropped by a hook: ...`) draw through no render hook of any plugin, so neither view can hide them. Tests `to-human/tests/mods/run.sh` (the `to-human-mods` gate). | | `output-styles/` | Output styles the harness auto-loads from the plugin. `to-human.md`: for sessions whose human reads a report stream (`` as a message sent to them, one that opens with a `⚠ Critical` block when work was lost, degraded, blocked, or significantly slowed, the block ending on how far that is handled and whether it needs them, and for what, and names in its body any lesser friction, with how to resolve or mitigate it, input asked for through `mcp__doperpowers__AskUserQuestion` with `async` when the model can work on; old ``, ``, `` and `` marks read as aliases of ``) instead of the transcript (selected per launch via `outputStyle`; design in `docs/doperpowers/specs/2026-09-05-sminos-human-stream-design.md`). | diff --git a/docs/doperpowers/plans/2026-10-15-arkho-desk-c2-bridge.md b/docs/doperpowers/plans/2026-10-15-arkho-desk-c2-bridge.md index df91621b69..d28c7c713a 100644 --- a/docs/doperpowers/plans/2026-10-15-arkho-desk-c2-bridge.md +++ b/docs/doperpowers/plans/2026-10-15-arkho-desk-c2-bridge.md @@ -148,6 +148,8 @@ with `sminos send`/`say` to a bridged member arrives as the human's words. §38.11, §20.15.14a), falling back to the inbox frame if the supervisor refuses. The frame's text for a bridged delivery is the same text the inbox frame carries today, so the model reads the same addressing. + (**[peer]**, 2026-10-18: a peer's `say` no longer takes the bridge; it is + the inbox frame, as DX5's last hop below says.) **Touches:** `hooks/mods/bridge.tsx`, `skills/sminos/scripts/sminos.py` (new verbs; `push_member`, `send_frame`'s callers), tests. @@ -466,7 +468,7 @@ M1 left them (with fw1/fw2). Changed rows are marked **[M2]**. | `interrupt` | M2 **[M2]** **[fw1]** **[record]** | `{from?}` | `{turnId, stopped: [], running: []}`: `$.turn.abort` on the main turn, then `TaskStop` (`$.tool.call`) on each Bash call **attributable to that turn** and running at the abort whose result names the background task the abort moved it to (`running in background with ID: `; their results awaited at most 3 s). **[fw1]** Attributable: a main-loop call made during that turn, or a call of a foreground subagent spawned in it (nested foreground subagents inherit their parent's turn); a background subagent's calls never are. **[fw1]** `running` names each task whose TaskStop failed (an `isError` result or a rejection): the interrupt is incomplete when it is not empty. Idle: `{turnId: null, stopped: [], running: []}`. **[record]** A plugin's abort leaves the model no marker (Esc leaves `[Request interrupted by user]`), and a Bash call it moved to the background reads as still running there: so once the stops settle the interrupt appends two user rows. **[marker]** The first is Esc's marker whole, `[Request interrupted by user]`, one text block: a transcript's reader knows a turn's interrupt by a row whose whole text is the marker and by nothing else (the engine's own readers, 2.1.300 `He`/`pQn`, and MAWS's `INTERRUPT_MARKERS`), so the earlier one-row shape, `[Request interrupted by user from the desk]` with the task lines after it, read as no interrupt. The second is the note: `The interrupt came from the desk.` (`from: "desk"`, which the daemon's RPC sends) or `The interrupt came from outside the terminal.`, then a line per task: stopped, `Task , the Bash command the interrupt moved to the background, was then stopped: it is not running, and no notification will come for it.`; in `running`, `… could not be stopped: it may still be running.` The engine keeps a plugin's user row `isMeta: true` with `origin: {kind: "plugin", name}` whatever the plugin asks (2.1.300 `m6r`; it refuses any other user row): the model reads both rows, and no reader takes the note for a prompt. A marker a hook refuses is followed by no note. Both must be in before the turn ends: a next turn already queued takes its messages before it waits for any hook (2.1.300 `ir`/`cn`), so an append in `turn.complete` would miss its first request. The engine waits for a Bash call's result after a turn abort, so each Bash call of the turn being interrupted is held in the `tool.call` hook, after its result, until both rows are in (at most 10 s, `INTERRUPT_HOLD_MS`): they are kept before the moved call's result row, and the next request carries them after that result, the marker then the note (live, 2.1.302). An interrupt with no Bash call running appends at once after the abort, racing only the turn's own unwinding. A second interrupt of a turn being interrupted leaves the first's record alone (the engine refuses its abort). Each row is named by a `row-seen` notice (door `note`). The transcript mirrors the rows on one line as `interrupt: [Request interrupted by user] ` (a newline there draws as U+FFFD). Nothing for an idle interrupt or a refused abort | | `model` | M2 **[M2]** | `{model, effort?}` — `model` an id, a family alias, `"default"` or null; `effort` one of `low medium high xhigh max`, or null | `{model, effort}` now in force (null: the session's own). Rewritten on every **main-loop** `turn.step` request, subagents untouched; never `$.config.set`. A family alias (`haiku`, `sonnet`, `opus`, `fable`) is resolved through the session's `ANTHROPIC_DEFAULT__MODEL`; one it does not map is refused, naming the id to use. Emits `model`; kept by the child | | `compact` | M2 **[M2]** | `{instructions?}` | During the main turn `{queued: true, turnId}`: held, and run inside that turn's `turn.complete` (a second while one is held keeps the first). Idle `{started: true}`: run at once. Either way the outcome follows on the stream as `compact` events carrying this command's `id` | -| `deliver` | M2 **[M2]** **[fw1]** **[#229]** **[midturn]** **[wake]** | `{key: {project, writer, seq}, text}` | `{submitted: true}` or `{duplicate: true}`; **[wake]** not-ok, `error` beginning `the session was stopped, so its prompt was refused`, when the engine refused the idle prompt for a stop. **[midturn]** While the main turn runs, `text` goes in as a steer's does: `$.session.append` of a user row (one text block), read at the main loop's next request — the next tool round, sminos's contract for a busy recipient — bound to the turn before the append is awaited, mirrored as the transcript line `delivered: ` (newlines as spaces), and sharing the steer's wake (**[fw2]**: one wake per turn that answered with a row of it unread; none for `error`/`refusal`). Idle, it is submitted with `$.prompt.submit({text, asUser: true})`, a turn of its own. A plugin prompt queued behind a turn read only at that turn's end (median 39 s, max 253 s observed), and an Esc empties the queue of plugin prompts (2.1.300 `zBt`). **[wake]** A message addressed to a session is meant to wake it (sminos's contract: an idle member starts a turn), while the engine refuses a plugin's prompt after an Esc until the human sends input (2.1.300 `k_e`, flag `tengu_plugin_input_refusal`), a refusal aimed at plugins acting on their own; `k_e` guards only `$.prompt.submit` and `$.command.run`, not the session's messaging socket. So the idle submit is awaited for up to 200 ms (`STOP_DROP_WAIT_MS`; the refusal is answered at once, a prompt taken resolves only when its turn starts): a drop for a stop — `the session was stopped and nobody has sent input since`, or `the prompt was removed from the queue before it ran` (Esc, or the stop-agents chord, emptying the queue) — is answered not-ok with the mark put back to its value before this delivery (unless a later delivery moved it) and nothing emitted, neither `submitted` nor `refused` (the child keeps refusals, which the daemon reports `failed`); `sminos_control.deliver` then returns False and `push_live` goes on to the supervisor's `reply` (a human's event to a `--bg` job) or the inbox frame, which starts a turn, warning in one line that the session was stopped. Otherwise `submitted` is emitted and answered once the wait passes. A drop for a stop that comes after the answer (an Esc emptied the queue the prompt waited in) is sent to the child as an `inbox` event carrying the text and the key, and the child sends the same frame. A delivered row the main turn was stopped (`aborted`) before any request read it wakes the session the same way: one `inbox` event per such turn, carrying a fixed wake text (`DELIVERY_WAKE`), which the child sends as a frame; a turn that answered with a delivered row unread, whose wake prompt the engine drops for a stop, sends `STEER_WAKE` that way too, unless a main-loop request has carried the row by the time the drop settles (the wake queued behind the human's prompt, whose turn read it before an Esc emptied the queue). A /clear lets every pending steer and wake go: an append that settles after it wakes nothing in the new conversation. Steers keep their own wake (none for `aborted`). Any other drop or rejection is `refused`. An Esc before the model answered hands the human's prompt back to the input box, and the prompt sent next goes in where that one stood (the same parent), so a row given after it is on a branch no request reads: each delivered row of a main turn that did not answer is checked when the next main turn starts against `$.session.messages({as: "api"})` (any user text block containing the text), and one the conversation lacks is appended again in that turn (`entered` again, the new row), read from the turn's second request or by the wake when the turn answers in one; one it holds is let go, as are a turn's rows once it answers, and every row when the main conversation is compacted (the bridge's `compact`, the compact tool's, the person's or the engine's): the conversation then holds them only in its summary, so each would read as missing and be given twice; a row a rewind left on a dead branch before the compaction is lost. A /clear lets them go too, a check still running at it included: nothing is appended to the conversation that ended. An unreadable conversation leaves the rows for the next turn's check. Live: `tests/mods/live-delivery-cancel-probe.py`. **The delivery key** is the log event's `(writer, seq)` — for a push carrying several events of one writer, the last. A key whose seq is at or below the highest already submitted for its writer is a duplicate and dropped (delivery goes per writer in seq order). The per-writer seqs are kept by the child across reloads; a /clear starts them anew (a new id is a new recipient). **[fw1]** The `delivered` event is emitted before the row is appended, so it is the next thing posted; **[wake]** for an idle submission it is emitted once the wait for a stop's refusal has passed. Deduplication across a module reload is **best effort when the reload lands mid-submission**: a key submitted whose `delivered` had not reached the child is not kept, and a redelivery of it may submit again (a residual of at-least-once delivery, §3). **[#229]** The key names the log's project (its id): every project numbers a writer's events on its own, and a writer id is per session, not per project, so one writer delivering to one session from two projects reaches seqs the other project already passed — keyed by `(writer, seq)` alone, those were dropped as duplicates and settled, lost with no trace. Deduplication is per writer **within a project**, against the mark `/`; a key naming no project (a CLI older than this) is held to the bare `` mark, which is all such a CLI raised, so no kept mark is migrated, and a session whose bare mark is past a project's own count takes that project's scoped keys from the first. The CLI takes a `duplicate` as delivered (with scoped keys it was submitted before) but says it, to the sender and in `daemon.log`, naming the event and the session. A refusal (`refused`, below) keeps the key whole, project included, and the daemon reports only those naming its own project, or none | +| `deliver` | M2 **[M2]** **[fw1]** **[#229]** **[midturn]** **[wake]** **[peer]** | `{key: {project, writer, seq}, text}` | **[peer]** Sent only for the owner's events (`sminos.owner_event`: a `human` by `/human` or `hub/`); a peer's event never reaches this op (below), and the mod refuses a key whose writer is a session's (`/`), so an older CLI that sends one falls back to the inbox. `{submitted: true}` or `{duplicate: true}`; **[wake]** not-ok, `error` beginning `the session was stopped, so its prompt was refused`, when the engine refused the idle prompt for a stop. **[midturn]** While the main turn runs, `text` goes in as a steer's does: `$.session.append` of a user row (one text block), read at the main loop's next request — the next tool round, sminos's contract for a busy recipient — bound to the turn before the append is awaited, mirrored as the transcript line `delivered: ` (newlines as spaces), and sharing the steer's wake (**[fw2]**: one wake per turn that answered with a row of it unread; none for `error`/`refusal`). Idle, it is submitted with `$.prompt.submit({text, asUser: true})`, a turn of its own. A plugin prompt queued behind a turn read only at that turn's end (median 39 s, max 253 s observed), and an Esc empties the queue of plugin prompts (2.1.300 `zBt`). **[wake]** A message addressed to a session is meant to wake it (sminos's contract: an idle member starts a turn), while the engine refuses a plugin's prompt after an Esc until the human sends input (2.1.300 `k_e`, flag `tengu_plugin_input_refusal`), a refusal aimed at plugins acting on their own; `k_e` guards only `$.prompt.submit` and `$.command.run`, not the session's messaging socket. So the idle submit is awaited for up to 200 ms (`STOP_DROP_WAIT_MS`; the refusal is answered at once, a prompt taken resolves only when its turn starts): a drop for a stop — `the session was stopped and nobody has sent input since`, or `the prompt was removed from the queue before it ran` (Esc, or the stop-agents chord, emptying the queue) — is answered not-ok with the mark put back to its value before this delivery (unless a later delivery moved it) and nothing emitted, neither `submitted` nor `refused` (the child keeps refusals, which the daemon reports `failed`); `sminos_control.deliver` then returns False and `push_live` goes on to the supervisor's `reply` (a human's event to a `--bg` job) or the inbox frame, which starts a turn, warning in one line that the session was stopped. Otherwise `submitted` is emitted and answered once the wait passes. A drop for a stop that comes after the answer (an Esc emptied the queue the prompt waited in) is sent to the child as an `inbox` event carrying the text and the key, and the child sends the same frame. A delivered row the main turn was stopped (`aborted`) before any request read it wakes the session the same way: one `inbox` event per such turn, carrying a fixed wake text (`DELIVERY_WAKE`), which the child sends as a frame; a turn that answered with a delivered row unread, whose wake prompt the engine drops for a stop, sends `STEER_WAKE` that way too, unless a main-loop request has carried the row by the time the drop settles (the wake queued behind the human's prompt, whose turn read it before an Esc emptied the queue). A /clear lets every pending steer and wake go: an append that settles after it wakes nothing in the new conversation. Steers keep their own wake (none for `aborted`). Any other drop or rejection is `refused`. An Esc before the model answered hands the human's prompt back to the input box, and the prompt sent next goes in where that one stood (the same parent), so a row given after it is on a branch no request reads: each delivered row of a main turn that did not answer is checked when the next main turn starts against `$.session.messages({as: "api"})` (any user text block containing the text), and one the conversation lacks is appended again in that turn (`entered` again, the new row), read from the turn's second request or by the wake when the turn answers in one; one it holds is let go, as are a turn's rows once it answers, and every row when the main conversation is compacted (the bridge's `compact`, the compact tool's, the person's or the engine's): the conversation then holds them only in its summary, so each would read as missing and be given twice; a row a rewind left on a dead branch before the compaction is lost. A /clear lets them go too, a check still running at it included: nothing is appended to the conversation that ended. An unreadable conversation leaves the rows for the next turn's check. Live: `tests/mods/live-delivery-cancel-probe.py`. **The delivery key** is the log event's `(writer, seq)` — for a push carrying several events of one writer, the last. A key whose seq is at or below the highest already submitted for its writer is a duplicate and dropped (delivery goes per writer in seq order). The per-writer seqs are kept by the child across reloads; a /clear starts them anew (a new id is a new recipient). **[fw1]** The `delivered` event is emitted before the row is appended, so it is the next thing posted; **[wake]** for an idle submission it is emitted once the wait for a stop's refusal has passed. Deduplication across a module reload is **best effort when the reload lands mid-submission**: a key submitted whose `delivered` had not reached the child is not kept, and a redelivery of it may submit again (a residual of at-least-once delivery, §3). **[#229]** The key names the log's project (its id): every project numbers a writer's events on its own, and a writer id is per session, not per project, so one writer delivering to one session from two projects reaches seqs the other project already passed — keyed by `(writer, seq)` alone, those were dropped as duplicates and settled, lost with no trace. Deduplication is per writer **within a project**, against the mark `/`; a key naming no project (a CLI older than this) is held to the bare `` mark, which is all such a CLI raised, so no kept mark is migrated, and a session whose bare mark is past a project's own count takes that project's scoped keys from the first. The CLI takes a `duplicate` as delivered (with scoped keys it was submitted before) but says it, to the sender and in `daemon.log`, naming the event and the session. A refusal (`refused`, below) keeps the key whole, project included, and the daemon reports only those naming its own project, or none | ##### Events (the mod's posts; the stream's lines) @@ -478,7 +480,7 @@ M1 left them (with fw1/fw2). Changed rows are marked **[M2]**. | `compact` / `start` | M2 **[M2]** | `id` — the `compact` command's id | | `compact` / `end` | M2 **[M2]** | `id`, and one of: `tokensBefore, tokensAfter` (null when the engine recorded none); `skip` (a hook vetoed it); `error` (the engine refused it, as during a turn) | | `delivered` | M2 **[M2]** (was reserved), **[#229]**, **[midturn]**, **[wake]** | `key: {project?, writer, seq}` (the command's key, whole), `outcome: "submitted" \| "duplicate" \| "refused" \| "entered"`, `reason?`, `uuid?` — `refused` follows a `submitted` when the engine later dropped or rejected the prompt for a reason other than a stop, or refused the row (**[midturn]**); `entered` names the row the delivery became once it is kept (DC8 G6), and is named again with the new row when an Esc's rewind left the first on a dead branch (**[midturn]**). **[wake]** None at all for an idle delivery the engine refused for a stop: the command's not-ok answer says it | -| `inbox` | **[wake]** | `text`, `key?` — a frame for the session's own inbox socket (its peer record's `messagingSocketPath`), which starts a turn where the engine refuses the mod's prompt (after an Esc): the wake for a delivered row a stopped turn left unread, or a delivery (`key`) whose prompt a stop dropped after `submitted`. **Consumed by the child, never handed to subscribers**: sent from the export thread with `sminos.send_frame`; nothing when no socket of the session answers, or when the export thread's queue is full, except that a `key` is then kept as a refusal, which the daemon reports `failed` | +| `inbox` | **[wake]** | `text`, `key?` — a frame for the session's own inbox socket (its peer record's `messagingSocketPath`), which starts a turn where the engine refuses the mod's prompt (after an Esc): the wake for a delivered row a stopped turn left unread, or a delivery (`key`) whose prompt a stop dropped after `submitted`. **Consumed by the child, never handed to subscribers**: sent from the export thread with `sminos.send_frame`, **[peer]** in the peer envelope: a wake's named `sminos bridge`, a delivery's by its log event's `via`, else `from` (`human` when the event cannot be read: the bridge takes only the owner's), never with a reply line; nothing when no socket of the session answers, or when the export thread's queue is full, except that a `key` is then kept as a refusal, which the daemon reports `failed` | | `kept` | M2 **[fw1]** **[fw2]** (the child's own) | `ok: false, error` when writing `.json` starts to fail (said once, however many batches it keeps failing; retried at the end of every batch); `ok: true` when a write works again after a failure | ##### Subscription (`GET /events`) @@ -499,26 +501,66 @@ for unknown, seatless, or another machine's). | `sminos model [--effort E]` | op `model` | refused, exit 4 | 0; 1; 2 bad effort | | `sminos compact [instructions]` | subscribes, then op `compact`; queued: says so and exits; started: waits (≤ 300 s from the command, **[fw1]** a monotonic deadline, not reset by other events) for its `compact` end and prints the counts | refused, exit 4 | 0; 1 error or no outcome | -##### Delivery's last hop (`push_live`, built, M2) **[M2]** +##### Delivery's last hop (`push_live`, built, M2) **[M2]** **[peer]** For a live recipient, `deliver_next` (through `push_member`) and `send` (through -`send_to_seat` / `send_to_member`) push the frame text the inbox would carry, in this -order: - -1. **The bridge**, when the push carries a log event: op `deliver` keyed by the last event +`send_to_seat` / `send_to_member`) push the frame text the inbox would carry. +**[peer]** Which way turns on whose the event is (every event of one push is one writer's, +so one side's). The owner's — `sminos.owner_event`: kind `human`, written by +`/human` (sminos run with no session) or `hub/` (a desk send, which the hub +stores only as a `human`) — takes paths 1 to 3 in this order. A peer's — a session's `say` or +`send`, and anything else — goes straight to path 3, the inbox frame, whether or not the +session has a bridge, so the engine delivers it as a message from another Claude session +(origin `peer`) and never as its user's words; the frame names its sender (below). + +1. **The bridge**, when the push carries an owner's log event: op `deliver` keyed by the last event (wait 10 s). Answered `ok` (submitted or duplicate): delivered (`sent`). No bridge answering: next path. Refused (nothing submitted): a warning, next path. Taken and not answered: uncertain (`sent?`, settled, never pushed again). -2. **The engine supervisor**, for a `human` event to a live `--bg` job (the engine record's +2. **The engine supervisor**, for an owner's event to a live `--bg` job (the engine record's `kind: "bg"` and 8-hex `jobId`): `{"proto": 1, "op": "reply", "short", "text", "auth": <~/.claude/daemon/control.key>, "nextTurn": true}` on `/tmp/cc-daemon-//control.sock`. `ok`: delivered; refused or unreachable: next path; written and unanswered: uncertain. -3. **The inbox frame**, as before. +3. **The inbox frame**, as before. **[peer]** Its content is the text inside the engine's own + peer envelope, as native SendMessage writes it (2.1.300 SPEC §38.23.1): + `\n\n`, the + sender the event's `via` or `from` (`human` for a steer's fallback), stripped of `"<>` and + control characters and cut to 64 code points as the engine strips one, and a closing tag in + the text escaped as the engine escapes one (`<\/cross-session-message`), so the envelope + round-trips the engine's parser and `from-name` reaches the row's `origin.name`. No `from` + address: nothing could answer one, and a member replies with `sminos say`. **[peer]** + For a session's event the body closes with one fixed line saying how, runnable by a + model that knows nothing of sminos (the CLI is not on PATH): + `[To reply, run in Bash: say "@/ "]`. + The tag names the sender in its own project: a session's `say` or `send` into another + project's log records that project as the event's `from_project`; otherwise it is the + log's own. So the reply reaches the sender from any project and machine. The owner's fallback frames and the steer's + carry no such line. Surfaces that draw the body drop this last line. No + `from-mode`: sminos does not know the sender's permission mode, so a receiver that + bypasses permission prompts holds the message for its user's approval + (`no-mode-asserted`, §38.22.2) and every other receiver accepts it. A push with no log event (a seat outside any project, the raw session-name route) goes straight to the inbox frame. +**[peer]** What the inbox path guarantees. The inbox answers nothing a sender could key on +(its receipts go to a `from` address, which the frame has none of), so the bridge's +once-per-key drop does not apply. Delivery keeps a peer's event to one push in the ordinary +retry cases: a write that fails before the frame is out (`SendFailed("before")`) left +nothing in the session and is retried; one that fails after (`"after"`) is settled +`sent?`/`uncertain` and never pushed again; and a push whose DX4 outcome could not be kept +is noted in the delivery record (`pushed: [seq, outcome]`), so the pass that records the +outcome settles it without pushing again. What remains at least once is a crash between +the write and the settlement. Against that the engine's own ingress guard drops a body +identical to the same sender's previous one within 30 s (the sender is the writing process +when the frame names no `from`), which covers only a re-push by the same process. The same +guard also limits each sender to a burst of 30 and 0.5 a second after, and a session to 50 +queued peer messages; a dropped frame is not reported to sminos, so a `delivered` outcome on +this path means the frame was written to the inbox. Ordering: one writer's events keep seq +order (the inbox processes frames in arrival order); the owner's and a peer's are different +writers, delivered per writer as before, with no order between them. + ##### Reserved for M3 (unchanged) The `/requests/…` routes, the `raised` and `settled` events, and the snapshot's `pending` @@ -1288,6 +1330,29 @@ a full backlog refuses too). The child's listen backlog is 128. disk. "A compaction's rows" in DX5 above is the rule. Date/Author: 2026-10-15, Arkho #78 builder. +- Decision ([peer], 2026-10-18, the owner; the desk composite's Decision + Log, "a peer's message arrives as a peer's", MAWS e72b11443): only the + owner's events go through the bridge as the human's words. A peer's + event takes the session's inbox socket, the channel native SendMessage + rides, so the terminal, MAWS and the desk draw it as a message from + another session and the model reads it under the engine's peer preamble + (not typed by your user; never approval). Owner is decided by kind and + writer together (`sminos.owner_event`): only a session-less sminos run + writes `/human`, and the hub stores only `human` drafts under + `hub/`; anything else, an unknown shape included, falls to the + peer side, which carries less authority. The frame carries the + engine's envelope with `from-name`, since without it the engine wraps a + bare text and MAWS's `parsePeerMessage` refuses it (no + `` opening), drawing the owner's bubble again. + Every inbox frame sminos writes takes the envelope, the bridgeless + owner's fallback and the steer's included. Rejected: asserting a + `from-mode` so a bypass-mode receiver accepts without approval — the + sender's mode is unknown here, and choosing the receiver's would launder + the engine's parity gate. The delivery record's `pushed` note replaces + the bridge key's protection for the one ordinary re-push a peer's event + could get (an outcome not kept). + Date/Author: 2026-10-18, smns-peer-inbox builder. + ## Surprises & Discoveries - (#78, 2.1.301, live) A manual compaction (the person's `/compact`, diff --git a/hooks/mods/bridge.tsx b/hooks/mods/bridge.tsx index d68652d91d..fc41f86773 100644 --- a/hooks/mods/bridge.tsx +++ b/hooks/mods/bridge.tsx @@ -48,15 +48,16 @@ export { AGENTS_REFRESH_MS } from './bridge-agents' * moved to the background, and before the turn ends appends Esc's marker and * a note saying so; * `model` rewrites the main loop's requests; - * `compact` runs between turns; `deliver` gives a log event as the human's - * words, once per key: into the running turn as a row, as a steer, else as a - * prompt of its own. A message to the session wakes it, though the engine - * refuses a plugin's prompt after an Esc: such a delivery is answered not-ok, - * so sminos sends it as an inbox frame, and a delivered row a stopped turn - * left unread wakes the session through its inbox (the child's `inbox` - * event). A row an Esc rewound past is given again. What must outlive a - * module reload (the model chosen, the keys delivered) the child keeps on - * disk: its hello hands it back. + * `compact` runs between turns; `deliver` gives the owner's log event as the + * human's words, once per key: into the running turn as a row, as a steer, + * else as a prompt of its own (a peer's goes to the session's inbox, and the + * bridge refuses one written by a session). A message to the session wakes + * it, though the engine refuses a plugin's prompt after an Esc: such a + * delivery is answered not-ok, so sminos sends it as an inbox frame, and a + * delivered row a stopped turn left unread wakes the session through its + * inbox (the child's `inbox` event). A row an Esc rewound past is given + * again. What must outlive a module reload (the model chosen, the keys + * delivered) the child keeps on disk: its hello hands it back. * * Code that takes `$` stays in this file: the hooks, the session start, the * child and the session it serves, control, and the wait for a decision. The @@ -611,6 +612,9 @@ async function runCompaction($: EngineInterface, request: { id: string; instruct } } +/** A session's writer, `/`: its events are a peer's words. */ +const SESSION_WRITER = /\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/ + /** * Why the engine drops a plugin's prompt when the human stopped the session * (2.1.300 `zBt`, `k_e`): Esc, or the stop-agents chord, empties the queue of @@ -622,7 +626,10 @@ const STOPPED_DROPS = new Set(['the prompt was removed from the queue before it export const STOPPED_ERROR = 'the session was stopped, so its prompt was refused' /** - * A log event for this session, as the human's words. While the main turn + * The owner's log event for this session, as the human's words (sminos sends + * only the owner's here; one a session wrote is refused whoever sends it, so + * its sender falls back to the session's inbox, where the engine delivers it + * as a peer's). While the main turn * runs it goes in as a user row, read at the loop's next request (its next * tool round), as a steer does; idle, it is submitted as a prompt, a turn of * its own. Either way `entered` names its row once it is kept, and `refused` @@ -654,6 +661,9 @@ async function deliver($: EngineInterface, key: unknown, text: unknown): Promise ) { return { ok: false, error: 'deliver needs key {project?, writer, seq} and text' } } + if (SESSION_WRITER.test(writer)) { + return { ok: false, error: "a session's event is a peer's, not the human's words: it goes to the session's inbox" } + } const delivery: DeliveryKey = { ...(project !== undefined && { project: project as string }), writer, seq: seq as number } const mark = project === undefined ? writer : `${project}/${writer}` const before = state.delivered.get(mark) diff --git a/package.json b/package.json index 83fe3d4dc9..2a0b210203 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "doperpowers", - "version": "7.176.0", + "version": "7.177.0", "description": "Doperpowers skills and runtime bootstrap for coding agents", "type": "module", "keywords": [ diff --git a/skills/sminos/SKILL.md b/skills/sminos/SKILL.md index 2e8f6d8205..aad2664685 100644 --- a/skills/sminos/SKILL.md +++ b/skills/sminos/SKILL.md @@ -134,6 +134,13 @@ project a name resolves across every project known here. `--group`, `--parent`, `--team`, and `retire --cascade` are gone: the project is the group and there is no tree. +A message a session sends (`say`, `send`) reaches a live member through +its inbox socket, as a message from another Claude session — never as +that member's human's words, so it approves nothing for them; a member +running with `bypassPermissions` holds it for its human's approval. Only +the human's own messages (sminos run outside any session, or the desk) +reach a session with the bridge mod as the human's words. + `send` is the direct message to one member, recorded like a tagged `say`: a live member reads it from its inbox socket, a stopped seat is resumed with it, and a seat-less session that is not live is refused (resuming diff --git a/skills/sminos/scripts/sminos.py b/skills/sminos/scripts/sminos.py index fa997ea5ad..ece0a65277 100644 --- a/skills/sminos/scripts/sminos.py +++ b/skills/sminos/scripts/sminos.py @@ -82,13 +82,14 @@ Messaging is one verb, `send`: to a live seat it writes a frame to the session's inbox socket — the socket the harness's native cross-session -SendMessage tool also rides — which the harness delivers as a peer message -(an idle session starts a new turn, a busy one reads it at its next tool -round); a stopped seat is resumed with the frame as its message. A session -with a bridge (the bridge mod) takes the same frame through it instead, as -the human's words, submitted once per log event however often it is -delivered; a --bg job with none takes a human's message through the engine -supervisor's `reply`. `resume` is +SendMessage tool also rides, wrapped in that tool's own envelope — which the +harness delivers as a peer message (an idle session starts a new turn, a busy +one reads it at its next tool round); a stopped seat is resumed with the +frame as its message. A peer's message always takes the inbox. The owner's +(owner_event) takes a session's bridge (the bridge mod) instead, as the +human's words, submitted once per log event however often it is delivered, +and a --bg job with no bridge takes it through the engine supervisor's +`reply`. `resume` is the process-level continuation the board pipeline relays through: a fresh `claude --bg --resume` carrying the invoking environment. @@ -118,6 +119,7 @@ import json import os import re +import shlex import shutil import signal import socket @@ -126,6 +128,7 @@ import sys import tempfile import time +import unicodedata import uuid as uuidlib from contextlib import contextmanager from typing import NamedTuple, NoReturn, Optional @@ -795,6 +798,16 @@ def writer_session(writer): return last if UUID_RE.match(last) else "" +def owner_event(e): + """Whether a log event is the owner's words: a `human` event written by + the owner's own writer — `/human` (sminos run with no session) + or the hub's `hub/` (a send from the desk, which the hub stores + only as a `human`). A session's `say`, and anything else, is a peer's: + it reaches a session as a peer message, never as the human's words.""" + writer = str(e.get("writer") or "") + return e.get("kind") == "human" and (writer.startswith("hub/") or writer.rsplit("/", 1)[-1] == "human") + + def writer_short(writer): session = writer_session(writer) return session[:8] if session else writer @@ -988,8 +1001,10 @@ def cursor_save(project, session_uuid, cursor): # Delivery records: per recipient session, per writer, the seq up to which # every event owed to it is settled, and the one pending event that failed -# with its attempt count. A deliverer never settles past an unsettled event of -# the same writer, so a later success cannot bury an earlier failure. +# with its attempt count — or that was pushed and could not be settled yet +# (`pushed`: [seq, outcome], deliver_next). A deliverer never settles past an +# unsettled event of the same writer, so a later success cannot bury an +# earlier failure. def delivered_load(project, session_uuid): @@ -1005,6 +1020,10 @@ def delivered_load(project, session_uuid): out[str(w)] = {"settled": int(v.get("settled") or 0), "pending": pending if type(pending) is int else None, "attempts": int(v.get("attempts") or 0)} + pushed = v.get("pushed") + if isinstance(pushed, list) and len(pushed) == 2 and type(pushed[0]) is int and pushed[0] == pending \ + and pushed[1] in PUSHED: + out[str(w)]["pushed"] = pushed return out @@ -1040,6 +1059,22 @@ def delivered_fail(project, session_uuid, writer, seq): return attempts +def delivered_pushed(project, session_uuid, writer, seq, outcome): + """Note that the writer's next owed event was pushed (`outcome`, one of + PUSHED) and stays unsettled: the next pass settles it on that outcome + without pushing it again. Kept best effort — a note that cannot be + written leaves the push to be made again, at least once as before.""" + try: + record = delivered_load(project, session_uuid) + w = _next_check(record, writer, seq) + record[writer] = {"settled": w["settled"], "pending": seq, + "attempts": w["attempts"] if w["pending"] == seq else 0, "pushed": [seq, outcome]} + write_json(project_path(project, "delivered", reader_key(session_uuid) + ".json"), record) + except OSError as err: + daemon_log("delivery: the push of %s:%d to %s could not be noted (%s): a later pass pushes it again" % ( + writer, seq, session_uuid, err)) + + # --------------------------------------------------------------- the family # # A member is a session whose latest membership event (joined/retired, in its @@ -1846,8 +1881,11 @@ def deliver_next(project, member): as `refused-read-only` (sminos_policy, read at each such event). Every settlement of an outside-origin event of a hub project leaves its outcome for the uplink (sminos_outcomes), recorded before the settlement: an - outcome that cannot be recorded leaves the event pending (pushed again by - a later pass, at least once as smns delivery is).""" + outcome that cannot be recorded leaves the event pending for a later + pass, which records it again; a push already made is noted in the + delivery record (delivered_pushed), so that pass settles it on the same + outcome without pushing it again (a peer's frame has no bridge key to + drop a second copy).""" session = member.session result = {"delivered": 0, "pending": 0, "outcomes": []} if not session: @@ -1856,14 +1894,18 @@ def deliver_next(project, member): outcomes = sibling_module("sminos_outcomes") with flocked(project_path(project, "delivered", reader_key(session) + ".lock")): events = log_read(project) + record = delivered_load(project, session) streams = {} - for e in _owed(events, session, delivered_load(project, session)): + for e in _owed(events, session, record): streams.setdefault(e["writer"], []).append(e) cursor = cursor_load(project, session) for writer, queue in streams.items(): outside = policy.outside_origin({"writer": writer}) + noted = _delivery(record, writer).get("pushed") for i, e in enumerate(queue): - if outside and policy.read_only(): + if noted and noted[0] == e["seq"]: + outcome = noted[1] + elif outside and policy.read_only(): outcome = outcomes.READ_ONLY else: outcome = push_event(project, member, session, e, events, cursor) @@ -1876,7 +1918,10 @@ def deliver_next(project, member): event_ref(e), project.name, session, DELIVERY_ATTEMPTS, outcome)) if outside and not outcomes.record(project, e, session, outcome, member): # Settled only once its outcome is kept: it stays pending, - # and the next pass pushes and records it again. + # and the next pass records it again — a push noted, so + # never made twice. + if outcome in PUSHED: + delivered_pushed(project, session, writer, e["seq"], outcome) result["pending"] += len(queue) - i break if outcome in PUSHED: @@ -2078,11 +2123,13 @@ def __init__(self, message, code=EXIT_UNKNOWN): def push_member(member, text, tagged, session=None, event=None, project=None): - """Push one frame to a member: to the live session (push_live: its bridge, - keyed by `event`, the log event the frame carries, and `project`, the id - of the project whose log holds it; its supervisor; its inbox socket); for - a seat, a resume when it is stopped and the message is addressed to it - (`tagged`). A session sminos did not spawn is never + """Push one frame to a member: to the live session (push_live: an + owner's event through its bridge, keyed by `event`, the log event the + frame carries, and `project`, the id of the project whose log holds it, + or its supervisor; a peer's, and whatever those do not take, through its + inbox socket); for a seat, a resume when it is stopped and the message is + addressed to it (`tagged`), the frame then its resumed prompt whoever sent + it. A session sminos did not spawn is never resumed: it is the human's to reopen, and reads the chat when it is back. Given `session`, a seat that now holds another session is not this message's recipient: delivery goes by session id, so a replacement cannot @@ -2174,6 +2221,8 @@ def cmd_say(a): fields = {"from": sender, "to": recipients if (recipients or wake) else "*", "text": a.text} if wake: fields["wake"] = True + if sid and home is not None and home.id != dest.id: + fields["from_project"] = home.id # the sender's own project, where a reply finds it try: e = log_append(dest, writer, kind, **fields) except OSError as err: @@ -3785,15 +3834,90 @@ def __init__(self, phase, err): self.phase = phase -def send_frame(path, text): +PEER_TAG = "cross-session-message" +# The engine's closing-tag escape for an envelope's body (2.1.300 `KXe`/`w`/`K`, +# cli.pretty.js ≈ 941747-941780), rebuilt so the envelope round-trips its parser: +# an opening angle or a lookalike, not already followed by a backslash, then a run +# of filler (anything but a word character or an angle), a slash or a lookalike, +# filler, and the tag name, invisible characters allowed between its letters and +# each `-` any dash or connector, then no word character. The match's angle +# becomes `<\\` (one backslash). +_OPEN = "<\uff1c\ufe64\u2329\u27e8\u3008\u2039\u02c2\u1438\u276c\u276e\u2770\u29fc\u226e\u227a\u22d6" +_CLOSE = ">\uff1e\ufe65\u232a\u27e9\u3009\u203a\u02c3\u1433\u276d\u276f\u2771\u29fd\u226f\u227b\u22d7" +_SLASH = "/\uff0f\u2215\u2044" +# \p{Pd}, \p{Pc} and the engine's extra dashes, as one class. +_DASH = ("\u002d\u005f\u02cd\u02d7\u058a\u05be\u0640\u07fa\u1400\u1806\u2010\u2011\u2012\u2013\u2014\u2015" + "\u2017\u203f\u2040\u2043\u2054\u207b\u208b\u2212\u2796\u2e17\u2e1a\u2e3a\u2e3b\u2e40\u2e5d\u301c" + "\u3030\u30a0\u30fc\ufe31\ufe32\ufe33\ufe34\ufe4d\ufe4e\ufe4f\ufe58\ufe63\uff0d\uff3f\uff70\U00010ead") +# Between the name's letters only invisible, combining and control characters +# (the engine's `qXe`: its `t$t`, `du` and C0/C1 controls). +_INVIS = ("\u00ad\u034f\u0600-\u0605\u061c\u06dd\u070f\u0890\u0891\u08e2\u115f\u1160\u17b4\u17b5" + "\u180b-\u180f\u200b-\u200f\u202a-\u202e\u2060-\u206f\u3164\ufe00-\ufe0f\ufeff\uffa0" + "\ufff0-\ufffb\U000110bd\U000110cd\U00013430-\U0001343f\U0001bca0-\U0001bca3" + "\U0001d173-\U0001d17a\U00016fe4\U000e0000-\U000e0fff" + "\u0300-\u0344\u0346-\u036f\u0483-\u0489\u0591-\u05bd\u05bf\u05c1\u05c2\u05c4\u05c5\u05c7" + "\u0610-\u061a\u064b-\u065f\u0670\u06d6-\u06dc\u06df-\u06e4\u06e7\u06e8\u06ea-\u06ed" + "\u1ab0-\u1aff\u1dc0-\u1dff\u20d0-\u20ff\u3099\u309a\ufe20-\ufe2f" + "\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f\u2028\u2029") +_LETTERS = "[%s]*" % _INVIS +_FILL = "[^A-Za-z0-9_\\-%s%s]*" % (re.escape(_OPEN), re.escape(_CLOSE)) +_FILL_SLASH = "[^A-Za-z0-9_\\-%s%s%s]*" % (re.escape(_OPEN), re.escape(_CLOSE), re.escape(_SLASH)) +CLOSING_TAG_RE = re.compile("[%s](?!\\\\)(?=%s[%s]%s%s(?:[^A-Za-z0-9_\\-]|$))" % ( + re.escape(_OPEN), _FILL_SLASH, re.escape(_SLASH), _FILL, + _LETTERS.join("[%s]" % re.escape(_DASH) if ch == "-" else re.escape(ch) for ch in PEER_TAG)), + re.IGNORECASE) +# What the engine strips from `from-name`: quotes and angles, then control, +# format, surrogate and line/paragraph-separator characters. +NAME_DROP = ("Cc", "Cf", "Cs", "Zl", "Zp") + + +def reply_line(session, project=""): + """The body's closing line for a peer's frame: how to answer the session + that sent it, as a command a model that knows nothing of sminos can run + (the CLI is not on PATH, so it is named by this install's path, which the + recipient shares: delivery is always on its own machine). The tag names + the sender by its short in its own project (`@/`: the + event's `from_project` when it spoke into another project's log, else the log's + own), so a reply reaches it from any project and on any machine; the + envelope carries no `from` address, so SendMessage has none to answer. + The project part sits inside a double-quoted shell argument and arrives in + synced log events, so one that is not id-shaped (valid_name) is left out.""" + if not valid_name(project): + project = "" + return '[To reply, run in Bash: %s say "@%s%s "]' % ( + shlex.quote(os.path.join(SCRIPT_DIR, "sminos")), "%s/" % project if project else "", session[:8]) + + +def peer_envelope(text, sender="", reply_to="", project=""): + """The text wrapped as the engine's own SendMessage wraps a peer message + (2.1.300 SPEC §38.23.1): ``, the + body, the closing tag. The receiving engine reads `from-name` into the + message's origin (the speaker its transcript and MAWS draw) when the + envelope round-trips its parser: the name stripped as it strips one, and + a closing tag in the body escaped as it escapes one. No `from` address: + there is no inbox a reply could reach through SendMessage — a member + answers with `sminos say`, which, given `reply_to` (the sending session), + the body's last line spells out (reply_line).""" + if reply_to: + text = "%s\n%s" % (text, reply_line(reply_to, project)) + name = "".join(ch for ch in re.sub(r'["<>]', "", sender) if unicodedata.category(ch) not in NAME_DROP) + name = name.strip()[:64].strip() # the engine keeps 64 code points, trimmed + body = CLOSING_TAG_RE.sub("<\\\\", text) + return "<%s%s>\n%s\n" % (PEER_TAG, ' from-name="%s"' % name if name else "", body, PEER_TAG) + + +def send_frame(path, text, sender="", reply_to="", project=""): """Write one message frame to a session's inbox socket. - The frame the harness documents for scripts is a plain user message; it is - delivered as a peer message ("another Claude session sent…"). The frame - carries no sender name, so callers put identity in the text's first line. + The frame is a plain user message whose content is the text in the peer + envelope (peer_envelope, naming `sender`, closed by how to answer the + session `reply_to` of the project `project` when a session sent it); the + harness delivers it as a + peer message ("Another Claude session sent a message"), never as its + user's words. Callers also put identity in the text's first line. Raises SendFailed(phase) — see the class. """ - frame = {"type": "user", "message": {"role": "user", "content": text}} + frame = {"type": "user", "message": {"role": "user", "content": peer_envelope(text, sender, reply_to, project)}} s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) s.settimeout(5) try: @@ -5395,11 +5519,12 @@ def compose_frame(sender, msg_id, msg): return "[sminos message from %s id=%s]\n%s" % (sender, msg_id, msg) -def send_to_seat(s0, text, msg_id, wait, record=None, session=""): +def send_to_seat(s0, text, msg_id, wait, record=None, session="", sender=""): """Deliver a frame to one seat under its lifecycle lock. The lock covers target selection, the socket delivery, and the status write, so a concurrent fill/resume/retire cannot slip between them. A live seat takes - the frame (push_live: its bridge, else its inbox socket) and is recorded + the frame (push_live: the owner's through its bridge, a peer's through its + inbox socket) and is recorded `working`; a stopped one is resumed with the frame as its message; vacant, retired, and gone seats are refused, pointing at the verb that can reach them. Given `session`, @@ -5465,7 +5590,7 @@ def resume(locks): control_module().push_live(peer, s["current"], delivery.payload(True), delivery.queue[-1], delivery.project.id) else: - send_frame(sock, text) + send_frame(sock, text, sender) except SendFailed as e: if e.phase == "before": # Nothing reached the peer (the session exited between the @@ -5612,6 +5737,9 @@ def record_message(project, session, alias, sender, via, text, msg_id, deliver=T fields = {"from": sender, "to": [{"session": session, "alias": alias}], "text": text, "msg_id": msg_id} if via and via != sender: fields["via"] = via + home = caller_scope() if sid else None + if home is not None and home.id != project.id: + fields["from_project"] = home.id # the sender's own project, where a reply finds it try: event = log_append(project, writer, "say" if sid else "human", **fields) return SendDelivery(project, session, writer, event) if deliver else event @@ -5622,7 +5750,8 @@ def record_message(project, session, alias, sender, via, text, msg_id, deliver=T def send_to_member(project, m, sender, via, text, msg_id): """send to a member that holds no seat here. A remote member's send is recorded and owed: its own machine's daemon delivers it. A local one is - reached while it runs (push_live: its bridge, else its inbox socket); a + reached while it runs (push_live: the owner's through its bridge, a peer's + through its inbox socket); a session sminos did not spawn is never resumed — it is the human's to reopen.""" if not m.local: @@ -5679,7 +5808,7 @@ def _send(a): if sid and res["current"] == sid: die("you cannot send to yourself") send_to_seat(res, text, msg_id, a.wait, record=lambda s: record_send(s, sender, frm, a.msg, msg_id), - session=target[2]) + session=target[2], sender=frm) return if target[0] == "member": project, m = target[1], target[2] @@ -5705,7 +5834,7 @@ def _send(a): if not (isinstance(r.get("cwd"), str) and r["cwd"] and resolve_project(r["cwd"]))] if len(peers) == 1: try: - send_frame(socket_path_of(peers[0]), text) + send_frame(socket_path_of(peers[0]), text, frm) except SendFailed as e: if e.phase == "after": die("delivery to session '%s' is UNCERTAIN — the frame was written but the close-out failed (%s); " diff --git a/skills/sminos/scripts/sminos_bridge.py b/skills/sminos/scripts/sminos_bridge.py index 6008e4b0e7..97e816a907 100644 --- a/skills/sminos/scripts/sminos_bridge.py +++ b/skills/sminos/scripts/sminos_bridge.py @@ -284,6 +284,27 @@ def _key(e): return "%s|%s|%s" % (e.get("turnId"), e.get("agentId"), e.get("step")) +def inbox_sender(key): + """Who an inbox frame the mod asks for is from, as its envelope names + them: a wake (no key) is the bridge's own; a delivery's is its log + event's sender (`via`, else `from`), read from the project's log. The + bridge takes only the owner's events, so one whose event cannot be read + is the human's.""" + if key is None: + return "sminos bridge" + project = S.find_project(key["project"], required=False) if isinstance(key.get("project"), str) else None + if project is not None: + try: + with open(S.writer_path(project, str(key.get("writer"))), encoding="utf-8", errors="replace") as f: + for line in f: + e = S.parse_event(line) + if e and e["seq"] == key.get("seq"): + return str(e.get("via") or e.get("from") or "human") + except Exception: # the lookup never costs the owner's delivery + pass + return "human" + + def _refused(kept, key, reason): """`kept` with a refusal of the delivery `key` added, once per key, the last REFUSED_KEPT, its reason cut to REASON_MAX; as it was for a key that @@ -782,7 +803,7 @@ def _send_inbox(self, text, key): reason = "no inbox socket of the session answers" else: try: - S.send_frame(S.socket_path_of(peer), text) + S.send_frame(S.socket_path_of(peer), text, inbox_sender(key)) return except S.SendFailed as err: if err.phase == "after": diff --git a/skills/sminos/scripts/sminos_control.py b/skills/sminos/scripts/sminos_control.py index 21eeffc8da..ec007f6010 100644 --- a/skills/sminos/scripts/sminos_control.py +++ b/skills/sminos/scripts/sminos_control.py @@ -8,13 +8,16 @@ and compact need the bridge and are refused, saying so. `push_live` is a delivery's last hop to a live session, for the log's -delivery and `send` alike: the bridge, which submits the text as the human's -words, keyed by the log event and its project (a key it has already -submitted is dropped, so delivery stays at least once); else, for a human's event to a --bg job, the +delivery and `send` alike. A peer's event (any but the owner's, S.owner_event) +is the inbox frame: the engine delivers it as a message from another Claude +session, never as its user's words. The owner's goes through the bridge, +which submits the text as the human's words, keyed by the log event and its +project (a key it has already submitted is dropped); else, to a --bg job, the supervisor's `reply` (human-origin input); else the inbox frame. Each path -carries the frame the inbox would, so the model reads the same addressing. -A session the human stopped (Esc) refuses the bridge's prompt, so its bridge -answers not-ok and the inbox frame, which starts a turn, carries it. +carries the same frame text, so the model reads the same addressing. A +session the human stopped (Esc) refuses the bridge's prompt, so its bridge +answers not-ok and the inbox frame, which starts a turn, carries the owner's +event (as a peer message: the only way left to wake it). """ import hashlib @@ -182,16 +185,26 @@ def supervisor_reply(short, text): def push_live(peer, session, text, event=None, project=None): """A delivery's last hop to a live session, `event` the last log event the - text carries (None for a frame no log holds) and `project` the id of the - project whose log holds it. Raises SendFailed as send_frame does: - "before" when nothing reached the session, "after" when it may have.""" - if event is not None and deliver(session, project, event, text): + text carries (None for a frame no log holds; every event of one push is + one writer's, so one side's) and `project` the id of the project whose + log holds it. Only the owner's events take the bridge or the supervisor, + the human's channels; a peer's is the inbox frame, named by its sender + and closed by how to answer it (S.reply_line). Raises SendFailed as + send_frame does: "before" when nothing reached the session, "after" when + it may have.""" + owner = event is not None and S.owner_event(event) + if owner and deliver(session, project, event, text): return - if event is not None and event.get("kind") == "human" and bg_job(peer): + if owner and bg_job(peer): took = supervisor_reply(bg_job(peer), text) if took is True: return - S.send_frame(S.socket_path_of(peer), text) + sender = str(event.get("via") or event.get("from") or "") if event is not None else "" + # from_project arrives in synced events and lands in a shell argument: only an id-shaped one is used + origin = event.get("from_project") if event is not None else None + origin = origin if isinstance(origin, str) and S.valid_name(origin) else project + S.send_frame(S.socket_path_of(peer), text, sender, S.writer_session(event["writer"]) if event is not None else "", + str(origin or "") if event is not None else "") # ------------------------------------------------------------------ the verbs @@ -239,7 +252,7 @@ def cmd_steer(a): print("steered %s through the engine supervisor (no bridge): read at its next step" % a.member) return S.warn("the supervisor did not take the steer (%s); it goes as a cross-session frame" % took) - S.send_frame(S.socket_path_of(peer), "[sminos steer from human]\n%s" % a.text) + S.send_frame(S.socket_path_of(peer), "[sminos steer from human]\n%s" % a.text, "human") except S.SendFailed as e: if e.phase == "after": S.die("the steer to %s is UNCERTAIN (%s); do not blindly repeat it" % (a.member, e), 1) diff --git a/skills/sminos/scripts/sminos_outcomes.py b/skills/sminos/scripts/sminos_outcomes.py index c0ec04a6c8..00c9c30920 100644 --- a/skills/sminos/scripts/sminos_outcomes.py +++ b/skills/sminos/scripts/sminos_outcomes.py @@ -14,10 +14,17 @@ session, or the stopped seat resumed with it), `uncertain` (the push was taken and its answer never came: settled, never pushed again), `recorded` (the recipient was not running and is pushed nothing), `refused-read-only` (the -machine's policy), `failed` (given up after DELIVERY_ATTEMPTS). An outcome -that cannot be written leaves the event unsettled, so it is pushed and -recorded again later; a crash between the record and the settlement records -it again too. The hub keeps the latest per event and recipient. +machine's policy), `failed` (given up after DELIVERY_ATTEMPTS). An owner's +event is pushed through the session's bridge, whose later refusal follows as +below; a peer's is written to the session's inbox socket, which answers +nothing more: its `delivered` says the frame was written, and the engine's +own gate may still hold it for its user's approval (a session that bypasses +permission prompts holds a peer message that asserts no permission mode, as +sminos's frame does) or drop it (its per-sender rate limit), unreported. An +outcome that cannot be written leaves the event unsettled, so it is recorded +again later — a push made is noted, so not made twice (sminos.deliver_next); +a crash between the push and the settlement pushes and records it again. The +hub keeps the latest per event and recipient. One outcome comes without a settlement: an event owed to a closed terminal session (a seat-less session of this machine whose membership ended with its diff --git a/tests/mods/bridge.test.ts b/tests/mods/bridge.test.ts index 3098c12e66..f80a341e1d 100644 --- a/tests/mods/bridge.test.ts +++ b/tests/mods/bridge.test.ts @@ -1532,6 +1532,17 @@ describe('deliver', () => { ]) }) + test("a session's event is refused: a peer's words never enter as the human's", async () => { + const h = harness() + await h.ready() + const key = { project: 'p1', writer: 'mac/5d000003-0000-4000-8000-0000000000c3', seq: 1 } + expect(await h.command({ op: 'deliver', key, text: '[sminos chat p 5d000003:1 from peer → @you]\nhi' })).toMatchObject({ ok: false }) + expect(await h.command({ op: 'deliver', key: { writer: 'hub/0123456789abcdef0123456789abcdef', seq: 1 }, text: 'owner' })) + .toMatchObject({ ok: true, result: { submitted: true } }) + expect(h.calls.filter((c) => c.op === 'submit').map((c) => c.args)).toEqual([{ text: 'owner', asUser: true }]) + expect(h.ofType('delivered').map((e) => e.key)).toEqual([{ writer: 'hub/0123456789abcdef0123456789abcdef', seq: 1 }]) + }) + test("each project numbers a writer's events on its own: the mark is per project, and a key naming none is held to the bare mark", async () => { const h = harness() await h.ready() diff --git a/tests/mods/live-peer-check.py b/tests/mods/live-peer-check.py new file mode 100755 index 0000000000..d0581cd664 --- /dev/null +++ b/tests/mods/live-peer-check.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +"""Live check that a peer's message reaches a bridged session as a peer's. + +What only a running engine shows, in a terminal session with the bridge mod: +- a peer session's `sminos send` (a `say` in the log, written by a session) + reaches the idle session through its inbox socket, never the bridge: the + session starts a turn on it, its transcript keeps the row with origin + `peer` named by the frame's sender (the engine read the peer envelope's + `from-name`), wrapped as the engine wraps a peer message, and no row + carries it as the human's words; and the session, which has no sminos + skill to read (its SKILL.md is left out of the plugin copy), answers the + peer through the reply line the frame closes with (`sminos say + @/`), + recorded in the project's log; +- a peer's `sminos say` during a running turn is read in that turn, at its + next tool round; +- the owner's `sminos send` (sminos run with no session) still reaches the + same session through the bridge, as the human's words (origin `plugin`, + `asUser`). + +It spends model calls (haiku, short prompts), so no suite runs it: run it by +hand with the model gateway's keys in the environment. Isolation is +live-bridge-check.py's, whose class it extends: a temporary HOME whose +.claude is the session's config dir, a temporary SMINOS_HOME, a copy of the +worktree's plugin, a tmux server of its own, the gateway keys in a 0600 env +file removed at the end. + + tests/mods/live-peer-check.py [--keep] # --keep: leave the temp dir (its transcript included) +""" + +import glob +import importlib.util +import json +import os +import re +import subprocess +import sys +import threading +import time +import uuid + +HERE = os.path.dirname(os.path.realpath(__file__)) +_spec = importlib.util.spec_from_file_location("live_bridge_check", os.path.join(HERE, "live-bridge-check.py")) +LB = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(LB) +NAME = LB.NAME +wait_for = LB.wait_for +PEER = "scout" +PEER_SID = str(uuid.uuid4()) +# The engine's peer wrapping as a reader finds it (MAWS's parsePeerMessage reads the same): +# an optional header, then the envelope sminos wrote, named by its sender. +ENVELOPE = re.compile(r'(?:^|\n)\n' % PEER) + + +def text_of(content): + if isinstance(content, str): + return content + return "\n".join(b.get("text") or "" for b in content or [] if isinstance(b, dict) and b.get("type") == "text") + + +class Peer(LB.Live): + def setup(self): + super().setup() + # Bash runs without a dialog: the reply and the mid-turn check need it. + with open(os.path.join(self.cfg, "settings.json"), "w") as f: + json.dump({"permissions": {"allow": ["Bash"]}}, f) + # No sminos context: the session reaches the CLI only through the frame. + os.unlink(os.path.join(self.plugin, "skills", "sminos", "SKILL.md")) + # The peer, a member of the project: a live process the engine's + # session registry names (as a session's record would), joined on sight. + os.makedirs(os.path.join(self.cfg, "sessions"), exist_ok=True) + holder = subprocess.Popen(["sleep", "3600"]) + self.procs.append(holder) + with open(os.path.join(self.cfg, "sessions", "%d.json" % holder.pid), "w") as f: + json.dump({"pid": holder.pid, "sessionId": PEER_SID, "cwd": self.work, "kind": "interactive", + "name": PEER, "nameSource": "user", "status": "idle"}, f) + + def peer_log(self): + """The project log's events addressed to the peer.""" + out = [] + for p in glob.glob(os.path.join(self.smh, "projects", "*", "log", "*.jsonl")): + with open(p) as f: + for line in f: + try: + e = json.loads(line) + except ValueError: + continue + if any(isinstance(r, dict) and r.get("session") == PEER_SID for r in e.get("to") or []): + out.append(e) + return out + + def cli(self, *args, peer=False): + env = self.local_env() + if peer: + env["CLAUDE_CODE_SESSION_ID"] = PEER_SID + return subprocess.run([self.sminos(), *args], cwd=self.work, env=env, capture_output=True, text=True, + timeout=120) + + def rows(self): + out = [] + for p in glob.glob(os.path.join(self.cfg, "projects", "*", self.session + ".jsonl")): + with open(p) as f: + for line in f: + try: + out.append(json.loads(line)) + except ValueError: + pass + return out + + def carrying(self, word): + """The transcript's rows whose message or attachment carries `word`.""" + return [r for r in self.rows() if r.get("type") in ("user", "attachment") and word in json.dumps( + [r.get("message"), r.get("attachment")])] + + def main_text(self, since): + return "".join(e["chunk"].get("text") or "" for e in self.seen( + lambda e: e.get("type") == "chunk" and e.get("agentId") is None and e["chunk"].get("kind") == "text", since)) + + def turn_starts(self, since, word): + return self.seen(lambda e: e.get("type") == "turn" and e.get("phase") == "start" and e.get("agentId") is None + and word in str(e.get("text")), since) + + def idle(self, seconds=120): + def quiet(): + try: + s, body = LB.request(self.endpoint(), "POST", "/command?wait=8000", {"op": "ping"}, timeout=15) + except OSError: + return False + return s == 200 and body["result"]["state"]["mainTurn"] is None + calm = None + deadline = time.time() + seconds + while time.time() < deadline: + if quiet(): + calm = calm or time.time() + if time.time() - calm >= 2: + return + else: + calm = None + time.sleep(0.5) + raise TimeoutError("the session did not go idle") + + def describe(self, rows): + return [(r.get("type"), (r.get("origin") or {}).get("kind"), (r.get("origin") or {}).get("name"), + (r.get("attachment") or {}).get("type")) for r in rows] + + def run(self): + self.setup() + self.start_session() + sock_dir = os.path.join(self.smh, "bridge") + found = wait_for("the bridge's socket", 90, lambda: [n for n in os.listdir(sock_dir) + if n.endswith(".sock") and not n.startswith(".")] + if os.path.isdir(sock_dir) else None) + self.session = found[0][:-5] + wait_for("the endpoint to answer", 20, self.status) + threading.Thread(target=self.subscribe_forever, daemon=True).start() + wait_for("the subscription", 10, lambda: self.subscriptions > 0) + self.idle() + + # -- a peer's send to the idle session + t = time.time() + r = self.cli("send", NAME, "Reply with exactly the word PEACH.", "--from", PEER, peer=True) + wait_for("the peer's turn", 90, lambda: self.turn_starts(t, "PEACH")) + self.wait_turn_end(t) + rows = self.carrying("Reply with exactly the word PEACH") + users = [r for r in rows if r.get("type") == "user"] + texts = [text_of((r.get("message") or {}).get("content")) for r in users] + asuser = [r for r in users if (r.get("origin") or {}).get("asUser") is True] + bridged = self.seen(lambda e: e.get("type") == "delivered", t) + self.check("a peer's send to the idle session starts a turn on it, through its inbox and not the bridge", + r.returncode == 0 and "sent" in r.stdout and not bridged, + "%s; bridge deliveries %s; answered %r" % (r.stdout.strip(), bridged, self.main_text(t)[-40:])) + self.check("its row is a peer message named by its sender, wrapped as the engine wraps one, never the human's", + len(users) == 1 and (users[0].get("origin") or {}).get("kind") == "peer" + and (users[0].get("origin") or {}).get("name") == PEER and ENVELOPE.search(texts[0] or "") + and not asuser, + "rows %s; text %r" % (self.describe(rows), (texts[0] if texts else "")[:200])) + replies = [e for e in self.peer_log() if e.get("kind") == "say" + and str(e.get("writer", "")).endswith("/" + self.session)] + self.check("the session, with no sminos context, answers the peer through the frame's reply line", + texts and ('/%s "]' % PEER_SID[:8]) in texts[0] + and any("PEACH" in str(e.get("text")) for e in replies), + "replies %s" % [(e.get("from"), e.get("text")) for e in replies]) + self.check("the pane draws it as a message from another session, not as a prompt", + not re.search(r"❯ *\[sminos message from %s" % PEER, self.pane()), + "pane tail %r" % self.pane().rstrip().splitlines()[-6:]) + self.idle() + + # -- a peer's say during a running turn + marker = "lpc%d" % os.getpid() + t = time.time() + self.prompt("Run exactly this Bash command, then run it once more, then reply with one short sentence: " + "python3 -c 'import time; time.sleep(6); print(\"%s\")'" % marker) + wait_for("the Bash call", 90, lambda: self.seen( + lambda e: e.get("type") == "tool" and e.get("phase") == "call" and e.get("agentId") is None + and marker in json.dumps(e.get("input")), t)) + r = self.cli("say", "@%s Also include the word GRAPE in your final sentence." % NAME, peer=True) + self.wait_turn_end(t, 180) + self.idle(180) + starts = self.seen(lambda e: e.get("type") == "turn" and e.get("phase") == "start" and e.get("agentId") is None, t) + rows = self.carrying("Also include the word GRAPE") + self.check("a peer's say during a turn is read in that turn, as a peer's", + r.returncode == 0 and "GRAPE" in self.main_text(t) and len(starts) == 1 and rows + and all((x.get("origin") or {}).get("asUser") is not True for x in rows), + "%s; %d turn(s); rows %s; answered %r" % (r.stdout.strip(), len(starts), self.describe(rows), + self.main_text(t)[-80:])) + + # -- the owner's send: the human's words, through the bridge + t = time.time() + r = self.cli("send", NAME, "Reply with exactly the word PLUM.") + wait_for("the owner's turn", 90, lambda: self.turn_starts(t, "PLUM")) + self.wait_turn_end(t) + users = [x for x in self.carrying("Reply with exactly the word PLUM") if x.get("type") == "user"] + origin = users[0].get("origin") if users else None + self.check("the owner's send still reaches the session through the bridge, as the human's words", + r.returncode == 0 and len(users) == 1 and isinstance(origin, dict) and origin.get("asUser") is True + and self.seen(lambda e: e.get("type") == "delivered" and e.get("outcome") == "submitted", t) + and "PLUM" in self.main_text(t), + "%s; origin %s" % (r.stdout.strip(), origin)) + if self.keep: + print(" transcript: %s" % glob.glob(os.path.join(self.cfg, "projects", "*", self.session + ".jsonl"))) + + +def main(): + live = Peer("--keep" in sys.argv[1:]) + print("live peer check (%s):" % live.root, flush=True) + try: + live.run() + except Exception as e: # noqa: BLE001 — a check that cannot go on is a failed check + live.check("the check ran to its end", False, "%s: %s" % (type(e).__name__, e)) + print(" pane at the failure:\n" + "\n".join(" " + l for l in live.pane().rstrip().splitlines()[-25:])) + finally: + live.cleanup() + failed = [r for r in live.results if not r[1]] + print("%s: %d of %d checks passed" % ("FAILED" if failed else "passed", len(live.results) - len(failed), len(live.results))) + sys.exit(1 if failed else 0) + + +if __name__ == "__main__": + main() diff --git a/tests/sminos/envelope_parity.py b/tests/sminos/envelope_parity.py new file mode 100755 index 0000000000..101393021c --- /dev/null +++ b/tests/sminos/envelope_parity.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""The peer envelope sminos writes round-trips the engine's parser (2.1.300). + +The receiving engine keeps an envelope's `from-name` (the row's speaker) only +when re-serializing what it parsed gives back exactly the text it got: the +body's closing-tag lookalikes escaped by its `KXe`, the name stripped by its +normalizer. Each case below pins sminos's output for an input that one of +those touches, as the engine's own serializer produces it (checked against +the 2.1.300 parser, `_`/`Pgt` in cli.pretty.js, when this file was written). +""" + +import os +import sys +import tempfile + +T = tempfile.mkdtemp(prefix="smenv.") +os.environ["HOME"] = T +os.environ["SMINOS_HOME"] = os.path.join(T, "s") +sys.path.insert(0, os.path.join(os.path.dirname(os.path.realpath(__file__)), "..", "..", "skills", "sminos", "scripts")) +import sminos # noqa: E402 + +BODIES = [ + ("a b", "a <\\/cross-session-message> b"), + ("a b", "a <\\/ cross-session-message> b"), + ("a ", "a <\\/cross_session_message>"), + ("a ", "a <\\/CROSS-SESSION-MESSAGE>"), + ("a </cross-session-message>", "a <\\/cross-session-message>"), + ("a ", "a <\\/cro​ss-session-message>"), + ("a <\\/cross-session-message>", "a <\\/cross-session-message>"), + ("a ", "a "), + ("a ", "a "), +] +NAMES = [ + ("scout", "scout"), + ('sc"ot', "scout"), + ("sc‍out\x07", "scout"), + (" scout ", "scout"), + ("abc" + " " * 61 + "z", "abc"), + ("y" * 70, "y" * 64), +] + +failures = 0 +for given, want in BODIES: + got = sminos.peer_envelope(given, "x").split("\n")[1] + if got != want: + failures += 1 + print("FAIL body %r: got %r, want %r" % (given, got, want)) +for given, want in NAMES: + got = sminos.peer_envelope("t", given).split("\n")[0] + expect = '' % want + if got != expect: + failures += 1 + print("FAIL name %r: got %r, want %r" % (given, got, expect)) +if sminos.peer_envelope("t", "‍") != "\nt\n": + failures += 1 + print("FAIL a name that strips to nothing leaves no attribute") +HOSTILE = ['x"$(touch /tmp/pwn)"', "p`id`", 'a"; id; "', "a b", "p$IFS", "", "../x"] +for bad in HOSTILE: + line = sminos.reply_line("0123456789abcdef", bad) + if any(c in line.split("say ", 1)[1] for c in "$`;") or line.count('"') != 2 or "@0123" not in line: + failures += 1 + print("FAIL hostile project %r reached the reply line: %r" % (bad, line)) +for good in ["local-demo", "0e1d2c3b-aaaa-bbbb-cccc-ddddeeeeffff"]: + if '"@%s/01234567 ' % good not in sminos.reply_line("0123456789abcdef", good): + failures += 1 + print("FAIL id-shaped project %r dropped from the reply line" % good) +total = len(BODIES) + len(NAMES) + 1 + len(HOSTILE) + 2 +print("%s: %d of %d envelope cases" % ("FAILED" if failures else "ok", total - failures, total)) +sys.exit(1 if failures else 0) diff --git a/tests/sminos/run-sminos-tests.sh b/tests/sminos/run-sminos-tests.sh index 6b56276217..3131144f80 100755 --- a/tests/sminos/run-sminos-tests.sh +++ b/tests/sminos/run-sminos-tests.sh @@ -1523,7 +1523,7 @@ for first_live in (True, False): probes.append(cur) return live if (len(probes) == 1) == first_live else None sminos.peer_for_session = probe - sminos.send_frame = lambda path, text: actions.append("socket") + sminos.send_frame = lambda path, text, sender="": actions.append("socket") sminos.resume_session = lambda *a, **k: actions.append("resume") sminos.harness_row = lambda s: None with contextlib.redirect_stdout(io.StringIO()): @@ -1730,7 +1730,7 @@ sys.path.insert(0, sys.argv[1]) import sminos codes = [] for phase in ("before", "after"): - def boom(path, text, phase=phase): + def boom(path, text, sender="", phase=phase): raise sminos.SendFailed(phase, OSError("injected")) sminos.send_frame = boom try: @@ -2666,17 +2666,26 @@ live_peer() { # name session status "$SOCK_PID" "$2" "$1" "$3" "$TEST_ROOT/in-$1.sock" > "$HOME/.claude/sessions/pl-$1.json" } inbox() { cat "$TEST_ROOT/in-$1.received"; } +# A frame's text inside the engine's peer envelope sminos wraps it in, less +# a peer's reply line (test-bridge-control.sh pins both). +UNWRAP='import re +def unwrap(c): + m = re.match(r"\n]*>\n([\s\S]*)\n$", c) + return re.sub(r"\n\[To reply, run in Bash: [^\n]*\]$", "", m.group(1)) if m else c +' frames() { # name → each frame the inbox received, decoded, newlines shown as " / " - python3 -c 'import json, sys + python3 -c "$UNWRAP"' +import json, sys for l in open(sys.argv[1]): if l.strip(): - print(json.loads(l)["message"]["content"].replace("\n", " / "))' "$TEST_ROOT/in-$1.received" 2>/dev/null || true + print(unwrap(json.loads(l)["message"]["content"]).replace("\n", " / "))' "$TEST_ROOT/in-$1.received" 2>/dev/null || true } bodies() { # name → each received frame's text below its header line, one frame a line - python3 -c 'import json, sys + python3 -c "$UNWRAP"' +import json, sys for l in open(sys.argv[1]): if l.strip(): - print(json.loads(l)["message"]["content"].split("\n", 1)[-1])' "$TEST_ROOT/in-$1.received" 2>/dev/null || true + print(unwrap(json.loads(l)["message"]["content"]).split("\n", 1)[-1])' "$TEST_ROOT/in-$1.received" 2>/dev/null || true } run_in() { # dir cmd... — run, from another directory local dir="$1"; shift @@ -2901,7 +2910,8 @@ p = mod.find_project(sys.argv[2]) print(len(mod.owed(p, next(m for m in mod.members(p) if m.session == sys.argv[3])))) PY_OWEDN } -last_frame() { python3 -c 'import json,sys; print(json.loads(open(sys.argv[1]).read().splitlines()[-1])["message"]["content"])' "$TEST_ROOT/in-$1.received"; } +last_frame() { python3 -c "$UNWRAP"' +import json,sys; print(unwrap(json.loads(open(sys.argv[1]).read().splitlines()[-1])["message"]["content"]))' "$TEST_ROOT/in-$1.received"; } DEMO_HUMAN="$SMINOS_HOME/projects/$DEMO/log/testhost.human.jsonl" # --from signs the frame; the log keeps who sent it. : > "$TEST_ROOT/in-b.received" @@ -4181,6 +4191,12 @@ while IFS= read -r line; do *) fail "mark parity: ${line#FAIL }" ;; esac done < <(python3 "$SCRIPT_DIR/marks_parity.py" "$REPO_ROOT/skills/sminos/scripts" 2>&1) +# A peer frame's envelope round-trips the engine's parser (envelope_parity.py). +if python3 "$SCRIPT_DIR/envelope_parity.py" >"$TEST_ROOT/envelope.out" 2>&1; then + pass "the peer envelope matches the engine's serializer ($(tail -1 "$TEST_ROOT/envelope.out"))" +else + fail "the peer envelope matches the engine's serializer"; sed 's/^/ /' "$TEST_ROOT/envelope.out" +fi # The desk's codes are the hub's: DX1's shared vectors pin them (codes_parity.py). while IFS= read -r line; do case "$line" in "ok "*) pass "codes parity: ${line#ok }" ;; *) fail "codes parity: ${line#FAIL }" ;; esac diff --git a/tests/sminos/test-bridge-control.sh b/tests/sminos/test-bridge-control.sh index 3947e71776..2d95a504b7 100755 --- a/tests/sminos/test-bridge-control.sh +++ b/tests/sminos/test-bridge-control.sh @@ -2,9 +2,10 @@ # # The bridge's control verbs and delivery (DC2 M2, DX5): `sminos steer`, # `interrupt`, `model`, `compact` through a session's endpoint, what the child -# keeps across a module reload, and a delivery's last hop — the bridge, keyed -# by the log event; the engine supervisor's `reply` for a human's event to a -# --bg job with no bridge; else the inbox frame. A stand-in for the mod +# keeps across a module reload, and a delivery's last hop — for the owner's +# event the bridge, keyed by the log event, then the engine supervisor's +# `reply` for a --bg job with no bridge; for a peer's, and else, the inbox +# frame in the engine's peer envelope. A stand-in for the mod # (fakemod) spawns the child and answers its command lines as the mod would; # a stand-in inbox socket and a stand-in supervisor control socket record what # reaches them. Hermetic: HOME and SMINOS_HOME are temporary (SMINOS_HOME @@ -444,12 +445,44 @@ L="$(last_op "$T/lines-b" deliver)" assert_contains "$(field_of "$L" text)" "[sminos message from human id=" "send's own frame went through the bridge" SENDSEQ="$(field_of "$L" key.seq)" assert_equals "$(( SENDSEQ > SEQ ))" "1" "keyed by its own event" +# A peer's say or send to a bridged member is a peer message: never the +# bridge, but the inbox frame, in the engine's own peer envelope naming its +# sender, so the engine delivers it as another session's. +PEER_SID=5f000005-0000-4000-8000-0000000000c5 +P1="$(field_of "$(cat "$R/.smns")" project)" +# The peer is a live member of the project (no inbox: it is reached as recorded). +printf '{"pid":%s,"sessionId":"%s","name":"peerone","nameSource":"user","kind":"interactive","status":"busy","cwd":"%s"}\n' \ + "$$" "$PEER_SID" "$R" > "$HOME/.claude/sessions/peerone.json" +N="$(count_op "$T/lines-b" deliver)" +set +e; OUT="$(cd "$R" && CLAUDE_CODE_SESSION_ID="$PEER_SID" "$SMINOS" say "@rover from a peer session" 2>&1)"; RC=$?; set -e +assert_rc 0 "$RC" "a peer's say to a bridged member exits 0" +assert_contains "$OUT" "rover: sent" "reporting it sent" +assert_equals "$(count_op "$T/lines-b" deliver)" "$N" "and not through the bridge" +until_true 100 grep -q "from a peer session" "$T/rover-inbox" || true +FRAME="$(grep "from a peer session" "$T/rover-inbox" | tail -1)" +CONTENT="$(field_of "$FRAME" message.content)" +assert_equals "$(head -1 <<<"$CONTENT")" '' \ + "but its inbox, in the engine's peer envelope naming the sender" +assert_contains "$(sed -n 2p <<<"$CONTENT")" "[sminos chat ctldemo 5f000005:" "around the frame the bridge would have carried" +REPLY_LINE="$(tail -2 <<<"$CONTENT" | head -1)" +assert_equals "$REPLY_LINE" "[To reply, run in Bash: $(cd "$(dirname "$SMINOS")" && pwd -P)/sminos say \"@$P1/5f000005 \"]" \ + "its last line says how to answer the sending session: the CLI's path, the sender by project and short" +assert_equals "$(tail -1 <<<"$CONTENT")" "" "closed" +set +e; OUT="$(cd "$R" && CLAUDE_CODE_SESSION_ID="$PEER_SID" "$SMINOS" send rover "a peer's direct word" --from scout 2>&1)"; RC=$?; set -e +assert_rc 0 "$RC" "a peer's send to a bridged member exits 0" +assert_equals "$(count_op "$T/lines-b" deliver)" "$N" "and does not use the bridge either" +until_true 100 grep -q "direct word" "$T/rover-inbox" || true +CONTENT="$(field_of "$(grep "direct word" "$T/rover-inbox" | tail -1)" message.content)" +assert_contains "$CONTENT" $'\n[sminos message from scout id=' \ + "its inbox frame is named by the sender it signs as" + # The bridge refusing a delivery (it did not submit) leaves the inbox frame. mode refuse set +e; OUT="$(cd "$R" && "$SMINOS" say "@rover second try" 2>&1)"; RC=$?; set -e assert_contains "$OUT" "rover: sent" "a delivery the bridge refuses still reaches the member" until_true 100 test -s "$T/rover-inbox" || true assert_contains "$(cat "$T/rover-inbox" 2>/dev/null)" "second try" "through its inbox socket" +assert_not_contains "$(grep "second try" "$T/rover-inbox")" "To reply" "with no reply line: the owner is answered in the session, not by say" # A session the human stopped (Esc): the engine refuses the mod's prompt, so # the bridge answers not-ok and the inbox frame, which starts a turn, carries it. mode stopped @@ -470,8 +503,10 @@ ctl post "$SOCK" '[{"type":"inbox","text":"[sminos bridge]\nA message for you wa until_true 100 test -s "$T/rover-inbox" || true FRAME="$(tail -1 "$T/rover-inbox" 2>/dev/null || true)" [ -n "$FRAME" ] || FRAME='{}' -assert_equals "$(field_of "$FRAME" message.content)" "[sminos bridge] -A message for you was added" "the child sends the mod's inbox event as a frame to the session's own socket" +assert_equals "$(field_of "$FRAME" message.content)" " +[sminos bridge] +A message for you was added +" "the child sends the mod's inbox event as a frame to the session's own socket, the bridge's own" assert_equals "$(field_of "$FRAME" type)" "user" "as the inbox frame sminos sends" # With no socket of the session answering, a delivery the frame should have # carried is kept as a refusal, for the daemon to report `failed`. @@ -522,6 +557,24 @@ assert_not_contains "$OUT" "already delivered" "with no duplicate said" assert_equals "$([ -s "$T/rover-inbox" ] && echo framed || echo none)" "none" "and nothing went to the inbox socket" printf '{"pid":%s,"sessionId":"%s","name":"rover","nameSource":"user","kind":"interactive","status":"idle","cwd":"%s","messagingSocketPath":"%s"}\n' \ "$$" "$SID" "$R" "$INBOX" > "$HOME/.claude/sessions/rover.json" +# A peer of the second project speaking into the first: its frame's reply line +# names the peer in its own project, and runs as written from the recipient's. +printf '{"pid":%s,"sessionId":"%s","name":"peerone","nameSource":"user","kind":"interactive","status":"busy","cwd":"%s"}\n' \ + "$$" "$PEER_SID" "$R2" > "$HOME/.claude/sessions/peerone.json" +set +e; OUT="$(cd "$R2" && CLAUDE_CODE_SESSION_ID="$PEER_SID" "$SMINOS" say "@ctldemo/rover across projects" 2>&1)"; RC=$?; set -e +assert_rc 0 "$RC" "a peer's say into another project exits 0" +until_true 100 grep -q "across projects" "$T/rover-inbox" || true +CONTENT="$(field_of "$(grep "across projects" "$T/rover-inbox" | tail -1)" message.content)" +REPLY_LINE="$(tail -2 <<<"$CONTENT" | head -1)" +assert_contains "$REPLY_LINE" "say \"@$P2/5f000005 \"]" "its reply line names the sender's own project" +REPLY_CMD="${REPLY_LINE#\[To reply, run in Bash: }" +REPLY_CMD="${REPLY_CMD%\]}" +REPLY_CMD="${REPLY_CMD//the answer}" +set +e; OUT="$(cd "$R" && CLAUDE_CODE_SESSION_ID="$SID" bash -c "$REPLY_CMD" 2>&1)"; RC=$?; set -e +assert_rc 0 "$RC" "the reply line runs as written from the recipient's project" +assert_contains "$OUT" "in ctlsecond" "recorded in the sender's project" +assert_contains "$OUT" "peerone: recorded" "addressed to the sender" +rm -f "$HOME/.claude/sessions/peerone.json" # A --bg job with no bridge: a human's event goes through the engine # supervisor's control socket (`reply`, human-origin), else the inbox frame. @@ -556,7 +609,7 @@ run "$SMINOS" steer "$BG" "stop after this file" assert_rc 0 "$RC" "a steer to a --bg job with no bridge exits 0" assert_contains "$OUT" "supervisor" "through the supervisor" assert_contains "$(tail -1 "$T/sup-in")" "stop after this file" "which got the text" -# A peer's say is a peer message, as today: the inbox frame. +# A peer's say is a peer message: the inbox frame. set +e; OUT="$(cd "$R" && CLAUDE_CODE_SESSION_ID="$SID" "$SMINOS" say "@bgone from a peer" 2>&1)"; RC=$?; set -e until_true 100 test -s "$T/bg-inbox" || true assert_contains "$(cat "$T/bg-inbox" 2>/dev/null)" "from a peer" "a session's say to it is the inbox frame" diff --git a/tests/sminos/test_policy_requests.py b/tests/sminos/test_policy_requests.py index 7de955cb20..3fc622ab7d 100644 --- a/tests/sminos/test_policy_requests.py +++ b/tests/sminos/test_policy_requests.py @@ -370,8 +370,12 @@ def test_an_outcome_that_cannot_be_written_leaves_the_event_owed(self): self.assertIn(e["seq"], [x["seq"] for x in sminos.owed(self.project, member_of(self.project, self.live.sid)) if x["writer"] == e["writer"]], "not settled without its outcome") self.deliver() - self.assertEqual(self.outcome_of(e)["outcome"], "delivered", "the next pass delivers and records it") + self.assertEqual(self.outcome_of(e)["outcome"], "delivered", "the next pass records it") self.assertEqual(sminos.owed(self.project, member_of(self.project, self.live.sid)), []) + self.assertEqual(sum("its outcome first" in f for f in self.live.inbox.frames), 1, + "pushed once: the first pass's push was noted, so the next does not push it again") + self.assertTrue(self.live.inbox.frames[-1].startswith("\n"), + "a peer's frame, in the engine's peer envelope") def test_a_closed_terminal_session_is_reported_recorded_until_it_is_back(self): """A session whose process ended is no member: an event sent to it