From a9ec56907ab5013604bc882e8ef75df9c40317a9 Mon Sep 17 00:00:00 2001 From: SSFSKIM Date: Sun, 18 Oct 2026 00:37:26 -0700 Subject: [PATCH 1/6] sminos: a peer's message reaches a bridged session as a peer's (7.173.0) Only the owner's log events (a human event by /human or hub/) go through the bridge as the human's words. A peer's event takes the session's inbox socket, wrapped in the engine's own peer envelope with from-name, so the engine, the terminal and MAWS read it as another session's message. The bridge mod refuses a session writer's event as a second guard. A push whose DX4 outcome cannot be kept is noted in the delivery record so the next pass does not push it again. Live check: tests/mods/live-peer-check.py. --- .claude-plugin/marketplace.json | 2 +- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- .../plans/2026-10-15-arkho-desk-c2-bridge.md | 74 ++++++- hooks/mods/bridge.tsx | 21 +- package.json | 2 +- skills/sminos/SKILL.md | 7 + skills/sminos/scripts/sminos.py | 125 ++++++++--- skills/sminos/scripts/sminos_control.py | 30 ++- skills/sminos/scripts/sminos_outcomes.py | 15 +- tests/mods/bridge.test.ts | 11 + tests/mods/live-peer-check.py | 205 ++++++++++++++++++ tests/sminos/run-sminos-tests.sh | 24 +- tests/sminos/test-bridge-control.sh | 33 ++- tests/sminos/test_policy_requests.py | 6 +- 15 files changed, 484 insertions(+), 75 deletions(-) create mode 100755 tests/mods/live-peer-check.py diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 9dee2a5025d..2d020265402 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.172.0", + "version": "7.173.0", "source": "./", "author": { "name": "SSFSKIM", diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 8a46f926b98..d121a76592b 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.172.0", + "version": "7.173.0", "author": { "name": "SSFSKIM", "email": "supremekim17@gmail.com" diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 06a1ef76f5d..989507f44bb 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "doperpowers", - "version": "7.172.0", + "version": "7.173.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/docs/doperpowers/plans/2026-10-15-arkho-desk-c2-bridge.md b/docs/doperpowers/plans/2026-10-15-arkho-desk-c2-bridge.md index b9b1307f724..d08c8251d98 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. @@ -463,7 +465,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]** | `{key: {writer, seq}, text}` | `{submitted: true}` or `{duplicate: true}`. `text` is submitted with `$.prompt.submit({text, asUser: true})` (queued until the session is idle; not awaited). **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 submission is made, so it is the next thing posted. 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) | +| `deliver` | M2 **[M2]** **[fw1]** **[peer]** | `{key: {writer, seq}, text}` | `{submitted: true}` or `{duplicate: true}`. **[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). `text` is submitted with `$.prompt.submit({text, asUser: true})` (queued until the session is idle; not awaited). **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 submission is made, so it is the next thing posted. 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) | ##### Events (the mod's posts; the stream's lines) @@ -495,26 +497,59 @@ 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`. 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` @@ -1284,6 +1319,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 aeb37361e7a..73075a735e9 100644 --- a/hooks/mods/bridge.tsx +++ b/hooks/mods/bridge.tsx @@ -48,8 +48,9 @@ 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` submits a log event as the human's - * words, once per key. What must outlive a module reload (the model chosen, + * `compact` runs between turns; `deliver` submits the owner's log event as + * the human's words, once per key (a peer's goes to the session's inbox, and + * the bridge refuses one written by a session). 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 @@ -558,17 +559,25 @@ 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}$/ + /** - * A log event for this session, submitted as the human's words: queued until - * the session is idle. Delivery is at least once and goes in seq order per - * writer, so a key at or below the writer's last submitted is a repeat, and - * dropped. + * The owner's log event for this session, submitted as the human's words: + * queued until the session is idle. Delivery is at least once and goes in seq + * order per writer, so a key at or below the writer's last submitted is a + * repeat, and dropped. sminos sends only the owner's events 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. */ function deliver($: EngineInterface, key: unknown, text: unknown): Answer { const { writer, seq } = (typeof key === 'object' && key !== null ? key : {}) as { writer?: unknown; seq?: unknown } if (typeof writer !== 'string' || writer === '' || !Number.isInteger(seq) || typeof text !== 'string') { return { ok: false, error: 'deliver needs key {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 = { writer, seq: seq as number } if (delivery.seq <= (state.delivered.get(writer) ?? 0)) { emit({ type: 'delivered', key: delivery, outcome: 'duplicate' }) diff --git a/package.json b/package.json index d5f877cb0b8..48fa9b5c554 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "doperpowers", - "version": "7.172.0", + "version": "7.173.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 71796d7285a..989c6549089 100644 --- a/skills/sminos/SKILL.md +++ b/skills/sminos/SKILL.md @@ -129,6 +129,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 4ffc2bbf64f..fb9fbe579a6 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. @@ -790,6 +791,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 @@ -983,8 +994,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): @@ -1000,6 +1013,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 @@ -1035,6 +1052,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 @@ -1578,8 +1611,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: @@ -1588,14 +1624,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) @@ -1608,7 +1648,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: @@ -1808,10 +1851,12 @@ def __init__(self, message, code=EXIT_UNKNOWN): def push_member(member, text, tagged, session=None, event=None): - """Push one frame to a member: to the live session (push_live: its bridge, - keyed by `event`, the log event the frame carries; 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, 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 @@ -3508,15 +3553,35 @@ def __init__(self, phase, err): self.phase = phase -def send_frame(path, text): +PEER_TAG = "cross-session-message" +NAME_DROP_RE = re.compile(r'["<>\x00-\x1f\x7f-\x9f\u2028\u2029]') + + +def peer_envelope(text, sender=""): + """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 the frame's first line names.""" + name = NAME_DROP_RE.sub("", sender).strip() + name = name[:64] # the engine keeps 64 code points + body = re.sub(r"\n%s\n" % (PEER_TAG, ' from-name="%s"' % name if name else "", body, PEER_TAG) + + +def send_frame(path, text, sender=""): """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`); 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)}} s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) s.settimeout(5) try: @@ -5104,11 +5169,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`, @@ -5173,7 +5239,7 @@ def resume(locks): if delivery: control_module().push_live(peer, s["current"], delivery.payload(True), delivery.queue[-1]) 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 @@ -5330,7 +5396,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: @@ -5387,7 +5454,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] @@ -5413,7 +5480,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_control.py b/skills/sminos/scripts/sminos_control.py index dc36a09aff7..bc7ec25bf54 100644 --- a/skills/sminos/scripts/sminos_control.py +++ b/skills/sminos/scripts/sminos_control.py @@ -8,11 +8,13 @@ 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 (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 -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. +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 (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 +same frame text, so the model reads the same addressing. """ import hashlib @@ -165,16 +167,20 @@ def supervisor_reply(short, text): def push_live(peer, session, text, event=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). 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, event, text): + text carries (None for a frame no log holds; every event of one push is + one writer's, so one side's). 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. 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, 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 "" + S.send_frame(S.socket_path_of(peer), text, sender) # ------------------------------------------------------------------ the verbs @@ -222,7 +228,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 1963825c848..f1c0b6b338c 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 df66455615e..36b40add23c 100644 --- a/tests/mods/bridge.test.ts +++ b/tests/mods/bridge.test.ts @@ -1500,6 +1500,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 = { 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('a deliver with no key or no text is refused', 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 00000000000..f7583bb333f --- /dev/null +++ b/tests/mods/live-peer-check.py @@ -0,0 +1,205 @@ +#!/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; +- 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 mid-turn check needs a tool round. + with open(os.path.join(self.cfg, "settings.json"), "w") as f: + json.dump({"permissions": {"allow": ["Bash"]}}, f) + + 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])) + 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/run-sminos-tests.sh b/tests/sminos/run-sminos-tests.sh index 1b77a3d04a5..187e0af6986 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 +# (test-bridge-control.sh pins the envelope itself). +UNWRAP='import re +def unwrap(c): + m = re.match(r"\n]*>\n([\s\S]*)\n$", c) + return 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" diff --git a/tests/sminos/test-bridge-control.sh b/tests/sminos/test-bridge-control.sh index a252cfcbe93..2b88f98041a 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 @@ -422,6 +423,30 @@ assert_rc 0 "$RC" "send to a bridged member exits 0" 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" assert_equals "$(( $(field_of "$L" key.seq) > 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 +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" +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 @@ -463,7 +488,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 3ecce0938f3..7de16a0260a 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 From c00d62e03f49f6e242050866eae4c1010c1b17c7 Mon Sep 17 00:00:00 2001 From: SSFSKIM Date: Sun, 18 Oct 2026 00:45:17 -0700 Subject: [PATCH 2/6] sminos: a peer frame closes with how to reply; the envelope matches the engine's serializer A peer's inbox frame ends with a fixed line naming the reply command (the CLI's path, say "@ "), so a session with no sminos context has a route that reaches the sender through the log. The body's closing-tag escape and the from-name normalization now follow the engine's own (KXe and its name normalizer), so the envelope round-trips its parser for lookalike tags and format characters too (reviewer-high P2); envelope_parity.py pins the cases. --- skills/sminos/scripts/sminos.py | 72 +++++++++++++++++++++---- skills/sminos/scripts/sminos_control.py | 4 +- tests/mods/live-peer-check.py | 37 ++++++++++++- tests/sminos/envelope_parity.py | 59 ++++++++++++++++++++ tests/sminos/run-sminos-tests.sh | 12 +++-- tests/sminos/test-bridge-control.sh | 3 ++ 6 files changed, 169 insertions(+), 18 deletions(-) create mode 100755 tests/sminos/envelope_parity.py diff --git a/skills/sminos/scripts/sminos.py b/skills/sminos/scripts/sminos.py index fb9fbe579a6..040dc908ad9 100644 --- a/skills/sminos/scripts/sminos.py +++ b/skills/sminos/scripts/sminos.py @@ -119,6 +119,7 @@ import json import os import re +import shlex import shutil import signal import socket @@ -127,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 @@ -3554,10 +3556,54 @@ def __init__(self, phase, err): PEER_TAG = "cross-session-message" -NAME_DROP_RE = re.compile(r'["<>\x00-\x1f\x7f-\x9f\u2028\u2029]') - - -def peer_envelope(text, sender=""): +# 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): + """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). `@` + names the sender in the project's log on any machine; the envelope + carries no `from` address, so SendMessage has none to answer.""" + return '[To reply, run in Bash: %s say "@%s "]' % ( + shlex.quote(os.path.join(SCRIPT_DIR, "sminos")), session[:8]) + + +def peer_envelope(text, sender="", reply_to=""): """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 @@ -3565,23 +3611,27 @@ def peer_envelope(text, sender=""): 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 the frame's first line names.""" - name = NAME_DROP_RE.sub("", sender).strip() - name = name[:64] # the engine keeps 64 code points - body = 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=""): +def send_frame(path, text, sender="", reply_to=""): """Write one message frame to a session's inbox socket. The frame is a plain user message whose content is the text in the peer - envelope (peer_envelope, naming `sender`); the harness delivers it as a + envelope (peer_envelope, naming `sender`, closed by how to answer the + session `reply_to` 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": peer_envelope(text, sender)}} + frame = {"type": "user", "message": {"role": "user", "content": peer_envelope(text, sender, reply_to)}} s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) s.settimeout(5) try: diff --git a/skills/sminos/scripts/sminos_control.py b/skills/sminos/scripts/sminos_control.py index bc7ec25bf54..ae0e40a6ba2 100644 --- a/skills/sminos/scripts/sminos_control.py +++ b/skills/sminos/scripts/sminos_control.py @@ -170,7 +170,7 @@ def push_live(peer, session, text, event=None): text carries (None for a frame no log holds; every event of one push is one writer's, so one side's). 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. Raises SendFailed as send_frame does: "before" when nothing + 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, event, text): @@ -180,7 +180,7 @@ def push_live(peer, session, text, event=None): if took is True: return sender = str(event.get("via") or event.get("from") or "") if event is not None else "" - S.send_frame(S.socket_path_of(peer), text, sender) + S.send_frame(S.socket_path_of(peer), text, sender, S.writer_session(event["writer"]) if event is not None else "") # ------------------------------------------------------------------ the verbs diff --git a/tests/mods/live-peer-check.py b/tests/mods/live-peer-check.py index f7583bb333f..33de52c0834 100755 --- a/tests/mods/live-peer-check.py +++ b/tests/mods/live-peer-check.py @@ -7,7 +7,10 @@ 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; + 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 @@ -57,9 +60,33 @@ def text_of(content): class Peer(LB.Live): def setup(self): super().setup() - # Bash runs without a dialog: the mid-turn check needs a tool round. + # 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() @@ -146,6 +173,12 @@ def run(self): 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 ('say "@%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:]) diff --git a/tests/sminos/envelope_parity.py b/tests/sminos/envelope_parity.py new file mode 100755 index 00000000000..416306e716e --- /dev/null +++ b/tests/sminos/envelope_parity.py @@ -0,0 +1,59 @@ +#!/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") +total = len(BODIES) + len(NAMES) + 1 +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 187e0af6986..a02ab23713d 100755 --- a/tests/sminos/run-sminos-tests.sh +++ b/tests/sminos/run-sminos-tests.sh @@ -2666,12 +2666,12 @@ 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 -# (test-bridge-control.sh pins the envelope itself). +# 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 m.group(1) if m else 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 "$UNWRAP"' @@ -3923,6 +3923,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 2b88f98041a..de921b06388 100755 --- a/tests/sminos/test-bridge-control.sh +++ b/tests/sminos/test-bridge-control.sh @@ -438,6 +438,8 @@ 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" +assert_equals "$(tail -2 <<<"$CONTENT" | head -1)" "[To reply, run in Bash: $(cd "$(dirname "$SMINOS")" && pwd -P)/sminos say \"@5f000005 \"]" \ + "its last line says how to answer the sending session, with the CLI's path" 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" @@ -453,6 +455,7 @@ set +e; OUT="$(cd "$R" && "$SMINOS" say "@rover second try" 2>&1)"; RC=$?; set - 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" mode busy # A --bg job with no bridge: a human's event goes through the engine From b982102abab13dd368f6ab3b67caf767e8d0ff18 Mon Sep 17 00:00:00 2001 From: SSFSKIM Date: Sun, 18 Oct 2026 00:49:58 -0700 Subject: [PATCH 3/6] sminos: a peer frame's reply line names the sender's project The reply tag is @/, so a member answering from another project still reaches the sender (reviewer-high P2); the bridge-control suite runs the line as written from a second project. --- .../plans/2026-10-15-arkho-desk-c2-bridge.md | 8 +++++- skills/sminos/scripts/sminos.py | 25 +++++++++++-------- skills/sminos/scripts/sminos_control.py | 3 ++- tests/mods/live-peer-check.py | 5 ++-- tests/sminos/test-bridge-control.sh | 21 +++++++++++++--- 5 files changed, 44 insertions(+), 18 deletions(-) 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 493ab32c977..fd54697d4b3 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 @@ -528,7 +528,13 @@ session has a bridge, so the engine delivers it as a message from another Claude 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`. No + 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 the project whose log holds the message, so the reply + reaches it 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. diff --git a/skills/sminos/scripts/sminos.py b/skills/sminos/scripts/sminos.py index 0b8ca8a5562..b2680a23235 100644 --- a/skills/sminos/scripts/sminos.py +++ b/skills/sminos/scripts/sminos.py @@ -3869,18 +3869,20 @@ def __init__(self, phase, err): NAME_DROP = ("Cc", "Cf", "Cs", "Zl", "Zp") -def reply_line(session): +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). `@` - names the sender in the project's log on any machine; the envelope - carries no `from` address, so SendMessage has none to answer.""" - return '[To reply, run in Bash: %s say "@%s "]' % ( - shlex.quote(os.path.join(SCRIPT_DIR, "sminos")), session[:8]) + recipient shares: delivery is always on its own machine). The tag names + the sender by its short in the project whose log holds the message + (`@/`), so a reply reaches it from any project and on + any machine; the envelope carries no `from` address, so SendMessage has + none to answer.""" + 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=""): +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 @@ -3891,24 +3893,25 @@ def peer_envelope(text, sender="", reply_to=""): 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)) + 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=""): +def send_frame(path, text, sender="", reply_to="", project=""): """Write one message frame to a session's inbox socket. 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` when a session sent it); the harness delivers it as a + 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": peer_envelope(text, sender, reply_to)}} + 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: diff --git a/skills/sminos/scripts/sminos_control.py b/skills/sminos/scripts/sminos_control.py index 49b98c4417a..c6fba622321 100644 --- a/skills/sminos/scripts/sminos_control.py +++ b/skills/sminos/scripts/sminos_control.py @@ -190,7 +190,8 @@ def push_live(peer, session, text, event=None, project=None): if took is True: return sender = str(event.get("via") or event.get("from") or "") if event is not None else "" - S.send_frame(S.socket_path_of(peer), text, sender, S.writer_session(event["writer"]) if event is not None else "") + S.send_frame(S.socket_path_of(peer), text, sender, S.writer_session(event["writer"]) if event is not None else "", + project or "") # ------------------------------------------------------------------ the verbs diff --git a/tests/mods/live-peer-check.py b/tests/mods/live-peer-check.py index 33de52c0834..d0581cd6645 100755 --- a/tests/mods/live-peer-check.py +++ b/tests/mods/live-peer-check.py @@ -9,7 +9,8 @@ `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 @`), + 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; @@ -176,7 +177,7 @@ def run(self): 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 ('say "@%s "]' % PEER_SID[:8]) in texts[0] + 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", diff --git a/tests/sminos/test-bridge-control.sh b/tests/sminos/test-bridge-control.sh index d07a9fd0149..acd11f4c7f3 100755 --- a/tests/sminos/test-bridge-control.sh +++ b/tests/sminos/test-bridge-control.sh @@ -445,6 +445,10 @@ assert_equals "$(( SENDSEQ > SEQ ))" "1" "keyed by its own event" # 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" @@ -453,11 +457,12 @@ assert_equals "$(count_op "$T/lines-b" deliver)" "$N" "and not through the bridg 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")" '' \ +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" -assert_equals "$(tail -2 <<<"$CONTENT" | head -1)" "[To reply, run in Bash: $(cd "$(dirname "$SMINOS")" && pwd -P)/sminos say \"@5f000005 \"]" \ - "its last line says how to answer the sending session, with the CLI's path" +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" @@ -496,6 +501,16 @@ assert_equals "$(field_of "$(cat "$KEPT")" "delivered.$P2/testhost/human")" "$(f "and it was submitted: the child keeps the second project's mark" 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" +# The peer's reply line, run as written by the member from the second +# project's directory, reaches the peer in the first project's log. +REPLY_CMD="${REPLY_LINE#\[To reply, run in Bash: }" +REPLY_CMD="${REPLY_CMD%\]}" +REPLY_CMD="${REPLY_CMD//the answer}" +set +e; OUT="$(cd "$R2" && CLAUDE_CODE_SESSION_ID="$SID" bash -c "$REPLY_CMD" 2>&1)"; RC=$?; set -e +assert_rc 0 "$RC" "a peer frame's reply line runs as written, from another project" +assert_contains "$OUT" "in ctldemo" "recorded in the sender's project" +assert_contains "$OUT" "peerone: recorded" "addressed to the sender" +rm -f "$HOME/.claude/sessions/peerone.json" 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" From 666cfb3eaf975b74376f226faea16f0d8dafdbda Mon Sep 17 00:00:00 2001 From: SSFSKIM Date: Sun, 18 Oct 2026 01:04:31 -0700 Subject: [PATCH 4/6] sminos: the reply line names the sender's own project A session's say or send into another project's log records its own project as from_project, and the peer frame's reply tag uses it, so a reply to a cross-project message reaches the sender (reviewer-high P2, second round). The bridge-control suite sends across projects and runs the reply line as written from the recipient's project. --- .../plans/2026-10-15-arkho-desk-c2-bridge.md | 5 +++-- skills/sminos/scripts/sminos.py | 13 +++++++---- skills/sminos/scripts/sminos_control.py | 2 +- tests/sminos/test-bridge-control.sh | 22 +++++++++++++------ 4 files changed, 28 insertions(+), 14 deletions(-) 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 fd54697d4b3..4a474355e29 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 @@ -532,8 +532,9 @@ session has a bridge, so the engine delivers it as a message from another Claude 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 the project whose log holds the message, so the reply - reaches it from any project and machine. The owner's fallback frames and the steer's + 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 diff --git a/skills/sminos/scripts/sminos.py b/skills/sminos/scripts/sminos.py index b2680a23235..453b06c0a07 100644 --- a/skills/sminos/scripts/sminos.py +++ b/skills/sminos/scripts/sminos.py @@ -2221,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: @@ -3874,10 +3876,10 @@ def reply_line(session, project=""): 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 the project whose log holds the message - (`@/`), 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 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.""" 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]) @@ -5731,6 +5733,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 diff --git a/skills/sminos/scripts/sminos_control.py b/skills/sminos/scripts/sminos_control.py index c6fba622321..a7e465b281d 100644 --- a/skills/sminos/scripts/sminos_control.py +++ b/skills/sminos/scripts/sminos_control.py @@ -191,7 +191,7 @@ def push_live(peer, session, text, event=None, project=None): return sender = str(event.get("via") or event.get("from") or "") if event is not None else "" S.send_frame(S.socket_path_of(peer), text, sender, S.writer_session(event["writer"]) if event is not None else "", - project or "") + str(event.get("from_project") or project or "") if event is not None else "") # ------------------------------------------------------------------ the verbs diff --git a/tests/sminos/test-bridge-control.sh b/tests/sminos/test-bridge-control.sh index acd11f4c7f3..fba3941d6bd 100755 --- a/tests/sminos/test-bridge-control.sh +++ b/tests/sminos/test-bridge-control.sh @@ -501,18 +501,26 @@ assert_equals "$(field_of "$(cat "$KEPT")" "delivered.$P2/testhost/human")" "$(f "and it was submitted: the child keeps the second project's mark" 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" -# The peer's reply line, run as written by the member from the second -# project's directory, reaches the peer in the first project's log. +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 "$R2" && CLAUDE_CODE_SESSION_ID="$SID" bash -c "$REPLY_CMD" 2>&1)"; RC=$?; set -e -assert_rc 0 "$RC" "a peer frame's reply line runs as written, from another project" -assert_contains "$OUT" "in ctldemo" "recorded in the sender's project" +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" -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 --bg job with no bridge: a human's event goes through the engine # supervisor's control socket (`reply`, human-origin), else the inbox frame. From f196e330a6a194e1f0f47624bd2949757e75e2ba Mon Sep 17 00:00:00 2001 From: SSFSKIM Date: Sun, 18 Oct 2026 02:41:58 -0700 Subject: [PATCH 5/6] sminos: id-shape from_project in the peer reply line; fail-safe inbox_sender lookup - push_live uses event from_project only when it is id-shaped (valid_name), else the log's project; reply_line drops a project part that is not id-shaped, so a hostile synced from_project never reaches the Bash argument. envelope_parity asserts it. - inbox_sender reads the writer file with errors=replace and falls back to 'human' on any lookup exception, so a torn UTF-8 append no longer loses the owner's delivery. --- skills/sminos/scripts/sminos.py | 6 +++++- skills/sminos/scripts/sminos_bridge.py | 4 ++-- skills/sminos/scripts/sminos_control.py | 5 ++++- tests/sminos/envelope_parity.py | 12 +++++++++++- 4 files changed, 22 insertions(+), 5 deletions(-) diff --git a/skills/sminos/scripts/sminos.py b/skills/sminos/scripts/sminos.py index 8d3caca5d3a..ece0a652775 100644 --- a/skills/sminos/scripts/sminos.py +++ b/skills/sminos/scripts/sminos.py @@ -3879,7 +3879,11 @@ def reply_line(session, project=""): 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.""" + 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]) diff --git a/skills/sminos/scripts/sminos_bridge.py b/skills/sminos/scripts/sminos_bridge.py index 857c17cece7..97e816a9075 100644 --- a/skills/sminos/scripts/sminos_bridge.py +++ b/skills/sminos/scripts/sminos_bridge.py @@ -295,12 +295,12 @@ def inbox_sender(key): 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")))) as f: + 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 OSError: + except Exception: # the lookup never costs the owner's delivery pass return "human" diff --git a/skills/sminos/scripts/sminos_control.py b/skills/sminos/scripts/sminos_control.py index 68da0de3f5a..ec007f6010a 100644 --- a/skills/sminos/scripts/sminos_control.py +++ b/skills/sminos/scripts/sminos_control.py @@ -200,8 +200,11 @@ def push_live(peer, session, text, event=None, project=None): if took is True: return 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(event.get("from_project") or project or "") if event is not None else "") + str(origin or "") if event is not None else "") # ------------------------------------------------------------------ the verbs diff --git a/tests/sminos/envelope_parity.py b/tests/sminos/envelope_parity.py index 416306e716e..101393021c9 100755 --- a/tests/sminos/envelope_parity.py +++ b/tests/sminos/envelope_parity.py @@ -54,6 +54,16 @@ if sminos.peer_envelope("t", "‍") != "\nt\n": failures += 1 print("FAIL a name that strips to nothing leaves no attribute") -total = len(BODIES) + len(NAMES) + 1 +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) From 74007c184d55c8e69e836d0b01af158ae9803b9b Mon Sep 17 00:00:00 2001 From: SSFSKIM Date: Sun, 18 Oct 2026 02:52:03 -0700 Subject: [PATCH 6/6] CLAUDE.md: the bridge delivers only the owner's events as the human's words --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 420f42fbaef..d72193d75d6 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`). |