diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md new file mode 100644 index 0000000..6467fa4 --- /dev/null +++ b/docs/PROTOCOL.md @@ -0,0 +1,193 @@ +# cc-peer protocol reference + +This document is the implement-it-yourself reference for Claude Code's local cross-session peer messaging, reverse-engineered and live-verified against Claude Code 2.1.269 by the cc-peer project. Machine-readable companions ship with the package (`cc-peer/schemas/*.schema.json`, JSON Schema draft 2020-12, generated from the same Zod definitions the SDK uses). + +Unofficial and unaffiliated with Anthropic; the protocol may change without notice between Claude Code releases. + +# Claude Code — Cross-Session Messaging UDS Protocol + +How `SendMessage` / `ListAgents` peer messaging actually travels between Claude Code processes: a per-session Unix domain socket with a file-backed bearer token, newline-delimited JSON framing, a permission-attestation consent model, and a receipted reverse channel — plus a cloud bridge leg for off-machine delivery. Reverse-engineered from the 2.1.269 binary and verified end to end with hand-rolled raw-socket clients (no harness tools): fresh Claude sessions received, held, and replied to injected messages; a standalone Python process registered as a named peer and exchanged messages, receipts, and subscriptions with real sessions. + +The consent design parallels agent-comms (ExaDev's cross-harness mesh; its consent design parallels this protocol)' six-obligation room-token work: credential-gated transport, kernel peer-pid verification, permission-mode attestation, hold-for-review, and return-address verification. + +## Transport and identity + +- Each interactive session binds `/tmp/cc-socks/.sock` (Unix socket, same-uid peers only). The registry entry `~/.claude/sessions/.json` records `messagingSocketPath`, `sessionId`, `bridgeSessionId`, `peerProtocol`, `peerFeatures` (`notify_idle`, `reply_across_default_dirs`, `artifact_yield`), name, status, and `procStart`. +- On startup the session writes a key file `~/.claude/sessions/..key` (mode 0600) where `` is `sha256` of the canonical socket path. Content: `{"peerToken":"<32 hex>","procStart":"...","pidDomain":"darwin"}`. A second in-process token, the `childToken`, is exported to subprocesses via `CLAUDE_CODE_MESSAGING_TOKEN` (with `CLAUDE_CODE_MESSAGING_SOCKET` and `CLAUDE_CODE_CHILD_SESSION=1`) — the inbox's startup banner documents the official injection recipe: `echo '{"type":"auth","token":"'$CLAUDE_CODE_MESSAGING_TOKEN'"}'; echo '{"type":"user",...}' | socat - UNIX-CONNECT:$CLAUDE_CODE_MESSAGING_SOCKET`. +- Sender-side vetting (`Pe` in the uds-client chunk): the target's key file is read, its owning pid checked alive with a matching proc-start token (recycled-pid defence), and optionally the connected peer's pid/uid read from the kernel. A live socket with no vouching key is refused on platforms where auth is mandatory; on macOS auth is optional, and an unrecognised token degrades to the unauthenticated path rather than being rejected. +- Address schemes (`Jf`): `uds:`, `bridge:`, `did:` (reserved — parsed as raw passthrough, no local resolution in 2.1.269), bare `*.sock` paths, and Windows named pipes. + +## Wire protocol + +One connection, one write, then close (~150 ms linger on macOS). Two newline-terminated JSON lines: auth, then the frame. Nothing is ever acknowledged on the same connection — delivery surfaces in the receiver's conversation, and status travels back as separate receipt connections. + +**Line 1, auth** — the target's own `peerToken` (peer-class) or `childToken` (child-class): + +```json +{"type":"auth","token":""} +``` + +**Line 2, user message** (`hnt`): + +```json +{ + "msgV": 1, + "msg_id": "", + "type": "user", + "message": {"role": "user", "content": ""}, + "priority": "next", + "from": "uds:/tmp/cc-socks/.sock", + "file_attachments": [{"path": "...", "file_name": "...", "file_size": 0, "sha256": "...", "media_type": "..."}] +} +``` + +- `"type":"user"` is load-bearing: any other type value is silently dropped after auth, with the connection left open and no error — a wrong type is indistinguishable from a delivery that never happened. +- `msgV` is exactly `1` in genuine traffic and not strictly validated (`2` and absent both deliver). `msg_id` is a `randomUUID()`. `priority` is `"next"` on the SendMessage path; `"later"` is accepted identically. A `session_id` field, when present, must match the receiver's session id or the frame is silently dropped (no hold, no receipt, no trace). +- Control frames share the connection shape with `"type":"control"` plus an `action` field — see Receipts and Idle subscriptions. + +## The envelope + +`message.content` must be the tag-wrapped envelope built by `uGe`/`zfe` and round-trip-checked by `HB`: + +``` +BODY +``` + +- Attribute **order is canonical and load-bearing**: `from, from-session, hop-chain, from-name, from-mode`. The parser regex matches that sequence only; a wrong order or an invalid attribute value fails the parse, the round-trip rebuild fails, and the entire envelope is treated as an opaque unattested body (held, raw tags shown in the preview). +- Grammar: `from` charset `[A-Za-z0-9%:_/.\-]`, max 300; `from-session` `[A-Za-z0-9_-]{1,80}`; `hop-chain` comma-joined 24-hex ids, max 32 entries at the grammar level; `from-name` free-ish text with `"` `<` `>` stripped and lookalikes normalised, max 80; `from-mode` one of the permission-mode enum (`bypass`, `prompting`, ...). Body: literal text, one newline inside each tag; occurrences of the closing tag are escaped to `<\`. + +## Consent: holds, attestation, and the self-sent verdict + +An unattested envelope arriving at a session that bypasses permission prompts is **held for human approval** ("The sender did not attest its permission mode and this session bypasses prompts"), with a Deny/Deliver dialog; `crossSessionInbound: accept` bypasses the hold. The setting's default (unset) is mode-parity: auto-deliver only on bypass↔bypass or prompting↔prompting; unattested senders are held only while the receiver bypasses. + +The self-sent verdict (`ye`) short-circuits the hold entirely. A message is self-sent when the connecting process's **ancestry includes the target pid**, or it presents the target's **childToken** with no contrary evidence (macOS walks `ps` ancestry; the childToken sits in every subprocess env, which is precisely the supported injection path). Verified live: an unattested frame with the session's own childToken delivered straight into the sending session's conversation mid-turn; a *foreign* childToken grants nothing — it falls to the ordinary hold. So child parity is strictly per-parent. + +## Registering a standalone peer and name discovery + +A standalone process becomes a first-class peer with three artifacts, all self-writable: + +1. Bind `/tmp/cc-socks/.sock`. +2. Write the key file with a self-generated `peerToken`. +3. Write the registry entry `.json` with `messagingSocketPath`, `name`, `status`, `peerFeatures`, and a correct `procStart`. + +**`procStart` must byte-match `LC_ALL=C TZ=UTC ps -o lstart= -p ` output** — the exact command Claude's own generators use, compared as a plain string. Two traps: macOS `ps` formats `lstart` per the locale (bare `ps` under en_GB emits day-before-month; `LC_ALL=C` emits ctime order), and the value is UTC, not local. A mismatch classifies the pid as `recycled` and the roster silently skips the entry. Store the forced-locale/UTC command's output verbatim. + +The roster builder (`listLivePeerSessions`) reads all `~/.claude/sessions/.json` files — no daemon involvement; the filesystem is authoritative — and includes an entry when it has a `sock`, is not the caller's own, is not spare/parked, its socket accepts a live connect probe, and its pid is `present` (alive with matching `procStart`; `gone` entries are swept, `recycled` skipped). `nameSource` and `status` are whitelisted on read; out-of-list values parse harmlessly to undefined. + +Verified chain: a Python peer registered this way appears in `ListAgents` within seconds and receives native `SendMessage` by bare name (`from-name` resolves from the sender's own registry entry). + +## Receipts and status + +`peer_message_status` is pushed from receiver to sender over a fresh connection to the sender's socket, authenticated with the sender's own peerToken: + +```json +{"type":"control","action":"peer_message_status","status":"held","reason":"","from":"uds:/tmp/cc-socks/.sock","orig_msg_id":"","msgV":1,"msg_id":""} +``` + +All statuses verified on the wire: + +| Status | When | Extra fields | +|---|---|---| +| `held` | unattested message entered the approval dialog | — | +| `delivered` | hold approved and released | — | +| `denied` | hold denied | — | +| `expired` | hold unapproved past TTL (~25 min; pending holds expire together) | `status_detail:"refused"` when the receiver refuses inbound | +| `dropped` | rejected at inbox admission | `drop_reason`, `dropped_msg_ids` | + +Clean fire-and-forget delivery pushes no receipt — receipts exist for holds and failures. + +Drop reasons (drop taxonomy): `duplicate` (same sender + same body hash within `dedupWindowMs` 30 s — **not** msg_id; identical msg_id with different bodies both deliver), `rate-limited` (token bucket below), `hop-loop` and `hop-runaway` (hop-chain guards below), `queue-full` (undelivered-queue cap). Verified live: `duplicate`, `rate-limited` (35-message burst against the 30-token bucket), `hop-runaway` (29-entry chain), `hop-loop` (12× target-own token). `queue-full` was attempted with 55 admitted messages queued behind a hold modal and did not fire — the effective cap is dynamically raised above the 50 code default (`tengu_harbor_kite_limits`, zod range 10-5000); it is the one code-verified variant, with a receipt frame identical to its captured siblings. + +## Idle subscriptions + +Subscribe: `{"type":"control","action":"notify_when_idle","from":"uds:...","from_mode":"bypass","msgV":1,"msg_id":""}`. The notice returns correlated by `orig_msg_id` = the subscription's `msg_id`: + +```json +{"type":"control","action":"peer_idle_notice","orig_msg_id":"...","state":"idle","finished_at":1789216922033,"detail":"","from":"uds:...","from_mode":"bypass","msgV":1,"msg_id":"..."} +``` + +`state` is `idle` (verified: fires immediately if the target is already idle) or `exited` (verified: fires on session shutdown, including a kill). `finished_at` is epoch-ms of the target's last turn end. + +## Guard rails and admission semantics + +Peer-guard defaults (`tengu_harbor_kite_limits`-overridable): `bucketCapacity:30, refillPerSecond:0.5, dedupWindowMs:30000, maxSelfHops:10, maxChainLength:28, maxTrackedSenders:256`. + +**Return-address verification** — the load-bearing reverse-channel rule: the receiver records the kernel-reported peer pid of each inbound connection and pushes receipts/notices only if the claimed `from` socket is owned by that same live process. Messages from a throwaway client claiming another peer's address deliver fine but receive nothing back ("unvettable reply target"). A standalone peer must send from the process that binds its socket. (Trap: macOS Python launchers — Homebrew and Xcode `Python.app` shims — fork before exec; the surviving process owns the socket and must be the sender.) + +**Hop tokens** — `ownUdsHopToken = HMAC-SHA256(key = randomBytes(32) at process init, msg = "uds:").hex().slice(0,24)`: per-boot ephemeral, not externally computable. The loop-detection self-token set also covers the bridge address and a bridge-identity id. The loop guard fires at ≥ `maxSelfHops` (10) occurrences of a self-token — a single occurrence is harmless and delivers. Outbound replies to peer-origin messages stamp `i_e(own address)` into the chain, so a target's token is **mintable** by asking it to reply once and reading the chain it emits; a 12×-token chain then triggers `hop-loop`. Chains over 32 entries fail envelope parsing (hold as unattested); 29-32-entry chains parse but trip `hop-runaway` at the > 28 guard. + +Also enforced: a max line cap (`message_too_large`), symlink refusal on reply targets, and stale-socket refusal keyed to pid liveness plus proc-start tokens. + +## artifact_yield (`yield_artifact_replies` family) + +Same-conversation primitive: one live process of a conversation asks another to hand over in-flight artifact-reply generation. + +- Request: `{action:"yield_artifact_replies", from, msg_id, session_id, slugs[<=16], reason:"resume"|"claim" (default), sent_at (epoch ms, ~4 s freshness window), claimed_at?, requester:{cwd?,tmux?}}` +- Answer: `{action:"artifact_replies_yielded", orig_msg_id, yielded?, not_held?, refused?}` +- Hand-back: `{action:"unyield_artifact_replies", orig_msg_id, slugs, stopped?}` + +Admission (verified live, both directions): the target looks up the requester's registry record by socket and requires its `sessionId` to equal the target's own conversation id, plus pid match. With a random registry `sessionId` the request is refused silently; with the target's `sessionId` written into the registry, the target admitted the request and answered `artifact_replies_yielded {yielded:[], not_held:["probe-artifact"]}`. Trust boundary: the conversation gate reads the same-user-writable registry — it gates capability between processes, not identity against the local user. + +## File transfer (`file_attachments`) + +Sender stages each file into `~/.claude/file-transfers/--` (0600) and attaches the descriptor array to the frame. Caps: 30 MiB per file, 16 per message, 1-day spool GC. Receiver validates each descriptor (absolute path, parent must be the spool, regular file, size, sha256 integrity), copies into `~/.claude/uploads//`, deletes the staged copy when spools are shared, and prepends `@""` mentions plus `[SendFile: ... was not delivered — ]` failure notes to the delivered body. + +Staging and the descriptor-carrying frame replicate exactly, but receive-side materialisation never executes on this account: the `tengu_send_file` flag is absent from the served Statsig evaluations (never served on, not merely cached-off), proven by a deliberately sha-mismatched descriptor producing no inline failure note and no uploads directory. The gate is a server rollout decision; everything up to it is documented and the send-side gate (`A6e()`) is explicit in code. + +## Cloud and bridge routing + +Local UDS is one leg. Every signed-in session carries a `bridgeSessionId` in its registry (universally present), mirrored at `https://claude.ai/code/`. Roster candidates without a `sock` (cloud-session / bridge-session kinds) route over the first-party Sessions API at `https://api.anthropic.com`: + +- `GET /v1/code/sessions`, `GET /v1/code/sessions/` — roster and detail +- `POST /v1/code/sessions//events` — signed, batched turn events (`anthropic/ccr-turn-event-uuid`, `anthropic/ccr-turn-linked-event-uuids`, `traceparent` headers) +- `GET .../events` and `.../events/stream` — sequenced, redialling stream with liveness timeouts and service-clock drift checks +- `mark_read`, `archive`, title, `bridge`, `device` (attestation binding), `teleport-events`, `move-to-cloud`, `client/presence`, `synced_file/*`, self-hosted runner/worker endpoints +- Auth: OAuth bearer plus trusted-device headers; `isolatePeerMachines` gates cross-machine sends + +Event signing: the payload is canonicalised with JCS (`claude-code-jcs@1`), signed with an external device key into an `anthropic.ccr.client_event.v1` attestation, and bound via `anthropic.ccr.create_session_bind.v1` / `session_bind.v1` messages carrying a `boundDeviceUuid`. Unattested events classify `bound_unattested` server-side. The device key is not extractable by design — the replication boundary for standalone cloud writes. + +Delivery to cloud targets is best-effort: sessions report `acceptsPeerMessages`, and senders surface "accepted by the server ... but delivery is not confirmed" when unreported. Verified live: a `SendMessage` to a `bridge:` address was server-accepted with exactly that caveat and arrived in the target's local transcript (the bridge id maps back to the conversation via cloud ingest or local-sock mirroring; the code supports both legs). + +## Adjacent: the daemon + +`~/.claude/daemon` supervises background/scheduled agents, not interactive sessions: `roster.json` (v5) tracks workers (pid, procStart, sessionId, rendezvous socket under `/tmp/cc-daemon-501//rv/`, pty socket), gated by `control.key` with uid-checked control connections. + +## Reproduction + +Minimal sender (auth + one user frame, single write): + +```python +import socket, json, uuid, time +TOKEN = "..key>" +SOCK, FROM = "/tmp/cc-socks/.sock", "uds:/tmp/cc-socks/.sock" +wrapper = f'\n\n' +frame = {"msgV": 1, "msg_id": str(uuid.uuid4()), "type": "user", + "message": {"role": "user", "content": wrapper}, + "priority": "next", "from": FROM} +s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM); s.settimeout(5) +s.connect(SOCK) +s.sendall((json.dumps({"type": "auth", "token": TOKEN}) + "\n" + json.dumps(frame) + "\n").encode()) +time.sleep(0.2); s.close() +``` + +A receiving peer is the mirror: bind the socket, write key file and registry (procStart from `LC_ALL=C TZ=UTC ps -o lstart=` verbatim), accept connections, verify the auth line against the peerToken, parse frames, and — for receipts and notices to arrive — send only from the socket-owning process. + +## Verification status + +| Feature | Status | +|---|---| +| Transport, auth (peer/child/unauthenticated), user frames, envelope grammar | verified live, both directions | +| Holds, attestation, self-sent verdict, `crossSessionInbound` parity | verified live | +| Peer registration, roster admission, name discovery | verified live (standalone peer in `ListAgents`, named `SendMessage`) | +| Receipts: held / delivered / denied / expired / dropped{duplicate, rate-limited, hop-loop, hop-runaway} | verified live | +| `queue-full` | code-verified; trigger attempted (55 queued), effective cap dynamically raised | +| Idle subscriptions (`idle`, `exited`) | verified live | +| artifact_yield admission + answer | verified live (refused and admitted paths); populated handover not exercised | +| File transfer | staging + wire replicated; receive path behind a never-served server flag (evidenced) | +| Cloud/bridge routing | `bridge:` addressing verified live; sessions-API surface and event-signer envelope extracted; device key not extractable by design | + +## Provenance + +Recovered by live reverse-engineering of the 2.1.269 binary and verified with hand-rolled raw-socket clients against fresh Claude Code sessions and a registered standalone peer: injected messages delivered and answered, receipts captured for every status, drop reasons triggered on demand, idle notices observed in both states, and the same-conversation yield admission demonstrated in both directions. Boundaries (device-attestation signing, the upstream file-transfer flag, the dynamically raised queue cap) are stated where they apply. + +The accompanying JSON Schemas are generated from the Zod definitions in `src/schemas/` — the same single source of truth the SDK runtime uses. diff --git a/eslint.config.ts b/eslint.config.ts index 504cd4e..baaabde 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -31,5 +31,17 @@ export default exadevConfig( ], }, }, + /* Test fixtures legitimately encode raw protocol values (16-byte tokens, + 24-hex hop ids, chain lengths); naming them would obscure the fixture. */ + { + files: ["**/*.test.ts"], + rules: { "@typescript-eslint/no-magic-numbers": "off" }, + }, + /* defineSchema attaches a guard to a live Zod class instance; spread would + destroy the prototype, so Object.assign is the only correct tool there. */ + { + files: ["src/schemas/define-schema.ts"], + rules: { "exadev/no-object-assign": "off" }, + }, eslintPluginPrettierRecommended, ); diff --git a/package.json b/package.json index f8d7cff..4aee532 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,8 @@ "koffi" ], "overrides": { - "eslint-plugin-jsdoc": "64.3.6" + "eslint-plugin-jsdoc": "64.3.6", + "conventional-changelog-writer": "9.2.1" } }, "devDependencies": { @@ -80,10 +81,12 @@ "@commitlint/types": "21.2.0", "@exadev/eslint-config": "2.11.0", "@semantic-release/changelog": "7.0.0", + "@semantic-release/commit-analyzer": "13.0.1", "@semantic-release/exec": "7.1.0", "@semantic-release/git": "11.0.1", "@semantic-release/github": "12.0.9", "@semantic-release/npm": "13.1.5", + "@semantic-release/release-notes-generator": "14.1.1", "@stryker-mutator/core": "10.0.0", "@stryker-mutator/vitest-runner": "10.0.0", "@types/node": "26.4.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1f690d8..c471942 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,6 +6,7 @@ settings: overrides: eslint-plugin-jsdoc: 64.3.6 + conventional-changelog-writer: 9.2.1 importers: @@ -33,6 +34,9 @@ importers: '@semantic-release/changelog': specifier: 7.0.0 version: 7.0.0(semantic-release@25.0.9(typescript@6.0.3)) + '@semantic-release/commit-analyzer': + specifier: 13.0.1 + version: 13.0.1(semantic-release@25.0.9(typescript@6.0.3)) '@semantic-release/exec': specifier: 7.1.0 version: 7.1.0(semantic-release@25.0.9(typescript@6.0.3)) @@ -45,6 +49,9 @@ importers: '@semantic-release/npm': specifier: 13.1.5 version: 13.1.5(semantic-release@25.0.9(typescript@6.0.3)) + '@semantic-release/release-notes-generator': + specifier: 14.1.1 + version: 14.1.1(semantic-release@25.0.9(typescript@6.0.3)) '@stryker-mutator/core': specifier: 10.0.0 version: 10.0.0(@types/node@26.4.1) @@ -56,7 +63,7 @@ importers: version: 26.4.1 commitlint: specifier: 21.2.2 - version: 21.2.2(@types/node@26.4.1)(conventional-commits-parser@7.1.2)(typescript@6.0.3) + version: 21.2.2(@types/node@26.4.1)(conventional-commits-filter@6.0.1)(conventional-commits-parser@7.1.2)(typescript@6.0.3) eslint: specifier: 10.10.0 version: 10.10.0(jiti@2.7.0) @@ -1700,15 +1707,19 @@ packages: resolution: {integrity: sha512-Rriac6ZrAlVm6cy9Bz4NSp+WMHpwNXoPIYex+HjCgduAVUSbnew29DQjQw0C4g9u3HtSYzGiGY+pdBXAZo+4aA==} engines: {node: '>=22'} - conventional-changelog-writer@8.4.0: - resolution: {integrity: sha512-HHBFkk1EECxxmCi4CTu091iuDpQv5/OavuCUAuZmrkWpmYfyD816nom1CvtfXJ/uYfAAjavgHvXHX291tSLK8g==} - engines: {node: '>=18'} + conventional-changelog-writer@9.2.1: + resolution: {integrity: sha512-StlYSmW3wLedRaqohJMpP3YuWiAqtgD0/cpsai9frdDkXv7rxj0hRbpJrubPYvJxJsjplONsOobEQnPuS9UgCg==} + engines: {node: '>=22'} hasBin: true conventional-commits-filter@5.0.0: resolution: {integrity: sha512-tQMagCOC59EVgNZcC5zl7XqO30Wki9i9J3acbUvkaosCT6JX3EeFwJD7Qqp4MCikRnzS18WXV3BLIQ66ytu6+Q==} engines: {node: '>=18'} + conventional-commits-filter@6.0.1: + resolution: {integrity: sha512-cs+LadpH7Kpw0M3k8wurk+sOVVDAENA0iK4OBOrkL94j5lEVYRJ4j3zd2bhY9qgzyrPqthdcYT3axzRN7AliMg==} + engines: {node: '>=22'} + conventional-commits-parser@6.4.0: resolution: {integrity: sha512-tvRg7FIBNlyPzjdG8wWRlPHQJJHI7DylhtRGeU9Lq+JuoPh5BKpPRX83ZdLrvXuOSu5Eo/e7SzOQhU4Hd2Miuw==} engines: {node: '>=18'} @@ -2117,11 +2128,6 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - handlebars@4.7.9: - resolution: {integrity: sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==} - engines: {node: '>=0.4.7'} - hasBin: true - has-flag@3.0.0: resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} engines: {node: '>=4'} @@ -2583,9 +2589,6 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - neo-async@2.6.2: - resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} - nerf-dart@1.0.0: resolution: {integrity: sha512-EZSPZB70jiVsivaBLYDCyntd5eH8NTSMOn3rB+HxwdmKThGELLdYv8qVIMWvZEFy9w8ZZpW9h9OB32l1rGtj7g==} @@ -3044,10 +3047,6 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} - source-map@0.6.1: - resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} - engines: {node: '>=0.10.0'} - source-map@0.7.6: resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} engines: {node: '>= 12'} @@ -3308,11 +3307,6 @@ packages: engines: {node: '>=14.17'} hasBin: true - uglify-js@3.19.3: - resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} - engines: {node: '>=0.8.0'} - hasBin: true - unconfig-core@7.5.0: resolution: {integrity: sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==} @@ -3493,9 +3487,6 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} - wordwrap@1.0.0: - resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} - wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -3878,13 +3869,13 @@ snapshots: '@colors/colors@1.5.0': optional: true - '@commitlint/cli@21.2.2(@types/node@26.4.1)(conventional-commits-parser@7.1.2)(typescript@6.0.3)': + '@commitlint/cli@21.2.2(@types/node@26.4.1)(conventional-commits-filter@6.0.1)(conventional-commits-parser@7.1.2)(typescript@6.0.3)': dependencies: '@commitlint/config-conventional': 21.2.2 '@commitlint/format': 21.2.2 '@commitlint/lint': 21.2.2 '@commitlint/load': 21.2.2(@types/node@26.4.1)(typescript@6.0.3) - '@commitlint/read': 21.2.1(conventional-commits-parser@7.1.2) + '@commitlint/read': 21.2.1(conventional-commits-filter@6.0.1)(conventional-commits-parser@7.1.2) '@commitlint/types': 21.2.0 tinyexec: 1.3.1 yargs: 18.1.0 @@ -3951,11 +3942,11 @@ snapshots: conventional-changelog-angular: 9.4.0 conventional-commits-parser: 7.1.2 - '@commitlint/read@21.2.1(conventional-commits-parser@7.1.2)': + '@commitlint/read@21.2.1(conventional-commits-filter@6.0.1)(conventional-commits-parser@7.1.2)': dependencies: '@commitlint/top-level': 21.2.0 '@commitlint/types': 21.2.0 - '@conventional-changelog/git-client': 3.1.2(conventional-commits-parser@7.1.2) + '@conventional-changelog/git-client': 3.1.2(conventional-commits-filter@6.0.1)(conventional-commits-parser@7.1.2) tinyexec: 1.3.1 transitivePeerDependencies: - conventional-commits-filter @@ -3987,12 +3978,13 @@ snapshots: conventional-commits-parser: 7.1.2 picocolors: 1.1.1 - '@conventional-changelog/git-client@3.1.2(conventional-commits-parser@7.1.2)': + '@conventional-changelog/git-client@3.1.2(conventional-commits-filter@6.0.1)(conventional-commits-parser@7.1.2)': dependencies: '@simple-libs/child-process-utils': 2.0.0 '@simple-libs/stream-utils': 2.0.0 semver: 7.8.5 optionalDependencies: + conventional-commits-filter: 6.0.1 conventional-commits-parser: 7.1.2 '@conventional-changelog/template@1.4.0': {} @@ -4457,7 +4449,7 @@ snapshots: '@semantic-release/commit-analyzer@13.0.1(semantic-release@25.0.9(typescript@6.0.3))': dependencies: conventional-changelog-angular: 8.3.1 - conventional-changelog-writer: 8.4.0 + conventional-changelog-writer: 9.2.1 conventional-commits-filter: 5.0.0 conventional-commits-parser: 6.4.0 debug: 4.4.3 @@ -4542,7 +4534,7 @@ snapshots: '@semantic-release/release-notes-generator@14.1.1(semantic-release@25.0.9(typescript@6.0.3))': dependencies: conventional-changelog-angular: 8.3.1 - conventional-changelog-writer: 8.4.0 + conventional-changelog-writer: 9.2.1 conventional-commits-filter: 5.0.0 conventional-commits-parser: 6.4.0 debug: 4.4.3 @@ -5096,9 +5088,9 @@ snapshots: comment-parser@1.4.8: {} - commitlint@21.2.2(@types/node@26.4.1)(conventional-commits-parser@7.1.2)(typescript@6.0.3): + commitlint@21.2.2(@types/node@26.4.1)(conventional-commits-filter@6.0.1)(conventional-commits-parser@7.1.2)(typescript@6.0.3): dependencies: - '@commitlint/cli': 21.2.2(@types/node@26.4.1)(conventional-commits-parser@7.1.2)(typescript@6.0.3) + '@commitlint/cli': 21.2.2(@types/node@26.4.1)(conventional-commits-filter@6.0.1)(conventional-commits-parser@7.1.2)(typescript@6.0.3) '@commitlint/types': 21.2.0 transitivePeerDependencies: - '@types/node' @@ -5130,16 +5122,18 @@ snapshots: dependencies: '@conventional-changelog/template': 1.4.0 - conventional-changelog-writer@8.4.0: + conventional-changelog-writer@9.2.1: dependencies: - '@simple-libs/stream-utils': 1.2.0 - conventional-commits-filter: 5.0.0 - handlebars: 4.7.9 - meow: 13.2.0 + '@conventional-changelog/template': 1.4.0 + '@simple-libs/stream-utils': 2.0.0 + argue-cli: 3.2.0 + conventional-commits-filter: 6.0.1 semver: 7.8.5 conventional-commits-filter@5.0.0: {} + conventional-commits-filter@6.0.1: {} + conventional-commits-parser@6.4.0: dependencies: '@simple-libs/stream-utils': 1.2.0 @@ -5593,15 +5587,6 @@ snapshots: graceful-fs@4.2.11: {} - handlebars@4.7.9: - dependencies: - minimist: 1.2.8 - neo-async: 2.6.2 - source-map: 0.6.1 - wordwrap: 1.0.0 - optionalDependencies: - uglify-js: 3.19.3 - has-flag@3.0.0: {} has-flag@4.0.0: {} @@ -5972,8 +5957,6 @@ snapshots: natural-compare@1.4.0: {} - neo-async@2.6.2: {} - nerf-dart@1.0.0: {} node-emoji@2.2.0: @@ -6379,8 +6362,6 @@ snapshots: source-map-js@1.2.1: {} - source-map@0.6.1: {} - source-map@0.7.6: {} spawn-error-forwarder@1.0.0: {} @@ -6622,9 +6603,6 @@ snapshots: typescript@6.0.3: {} - uglify-js@3.19.3: - optional: true - unconfig-core@7.5.0: dependencies: '@quansync/fs': 1.0.0 @@ -6730,8 +6708,6 @@ snapshots: word-wrap@1.2.5: {} - wordwrap@1.0.0: {} - wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 diff --git a/release.config.ts b/release.config.ts index db29f7c..c7a1857 100644 --- a/release.config.ts +++ b/release.config.ts @@ -6,7 +6,7 @@ const config: GlobalConfig = { branches: ["main"], plugins: [ [ - "semantic-release/commit-analyzer", + "@semantic-release/commit-analyzer", { preset: "conventionalcommits", releaseRules: [ @@ -25,7 +25,7 @@ const config: GlobalConfig = { }, ], [ - "semantic-release/release-notes-generator", + "@semantic-release/release-notes-generator", { preset: "conventionalcommits" }, ], ["@semantic-release/changelog", { changelogFile: "CHANGELOG.md" }], diff --git a/schemas/artifact-replies-yielded.schema.json b/schemas/artifact-replies-yielded.schema.json new file mode 100644 index 0000000..a96597b --- /dev/null +++ b/schemas/artifact-replies-yielded.schema.json @@ -0,0 +1,47 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "control" + }, + "action": { + "type": "string", + "const": "artifact_replies_yielded" + }, + "orig_msg_id": { + "type": "string", + "maxLength": 128 + }, + "yielded": { + "type": "string" + }, + "not_held": { + "type": "string" + }, + "refused": { + "type": "string" + }, + "msgV": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "msg_id": { + "type": "string", + "minLength": 1 + }, + "from": { + "type": "string" + } + }, + "required": [ + "type", + "action", + "orig_msg_id", + "msgV", + "msg_id" + ], + "additionalProperties": false +} diff --git a/schemas/auth-line.schema.json b/schemas/auth-line.schema.json new file mode 100644 index 0000000..a8e2790 --- /dev/null +++ b/schemas/auth-line.schema.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "auth" + }, + "token": { + "type": "string", + "pattern": "^[0-9a-f]{32}$" + } + }, + "required": [ + "type", + "token" + ], + "additionalProperties": false +} diff --git a/schemas/envelope-attributes.schema.json b/schemas/envelope-attributes.schema.json new file mode 100644 index 0000000..c69f985 --- /dev/null +++ b/schemas/envelope-attributes.schema.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "from": { + "type": "string", + "pattern": "^[A-Za-z0-9%:_/.\\\\-]{1,300}$" + }, + "fromSession": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]{1,80}$" + }, + "hopChain": { + "maxItems": 32, + "type": "array", + "items": { + "type": "string", + "pattern": "^[0-9a-f]{24}$" + } + }, + "fromName": { + "type": "string", + "minLength": 1, + "maxLength": 80 + }, + "fromMode": { + "type": "string", + "enum": [ + "bypass", + "prompting" + ] + } + }, + "required": [ + "from" + ], + "additionalProperties": false +} diff --git a/schemas/notify-when-idle.schema.json b/schemas/notify-when-idle.schema.json new file mode 100644 index 0000000..5cc5ef8 --- /dev/null +++ b/schemas/notify-when-idle.schema.json @@ -0,0 +1,42 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "control" + }, + "action": { + "type": "string", + "const": "notify_when_idle" + }, + "from": { + "type": "string", + "pattern": "^[A-Za-z0-9%:_/.\\\\-]{1,300}$" + }, + "from_mode": { + "type": "string", + "enum": [ + "bypass", + "prompting" + ] + }, + "msgV": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "msg_id": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "type", + "action", + "from", + "msgV", + "msg_id" + ], + "additionalProperties": false +} diff --git a/schemas/peer-idle-notice.schema.json b/schemas/peer-idle-notice.schema.json new file mode 100644 index 0000000..e75dcc9 --- /dev/null +++ b/schemas/peer-idle-notice.schema.json @@ -0,0 +1,62 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "control" + }, + "action": { + "type": "string", + "const": "peer_idle_notice" + }, + "orig_msg_id": { + "type": "string", + "minLength": 1 + }, + "state": { + "type": "string", + "enum": [ + "idle", + "exited" + ] + }, + "finished_at": { + "type": "number" + }, + "detail": { + "type": "string" + }, + "from": { + "type": "string", + "pattern": "^[A-Za-z0-9%:_/.\\\\-]{1,300}$" + }, + "from_mode": { + "type": "string", + "enum": [ + "bypass", + "prompting" + ] + }, + "msgV": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "msg_id": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "type", + "action", + "orig_msg_id", + "state", + "finished_at", + "from", + "msgV", + "msg_id" + ], + "additionalProperties": false +} diff --git a/schemas/peer-key-file.schema.json b/schemas/peer-key-file.schema.json new file mode 100644 index 0000000..c6aeada --- /dev/null +++ b/schemas/peer-key-file.schema.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "peerToken": { + "type": "string", + "pattern": "^[0-9a-f]{32}$" + }, + "procStart": { + "type": "string", + "minLength": 1 + }, + "pidDomain": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "peerToken", + "procStart", + "pidDomain" + ], + "additionalProperties": false +} diff --git a/schemas/peer-message-status.schema.json b/schemas/peer-message-status.schema.json new file mode 100644 index 0000000..1ccf213 --- /dev/null +++ b/schemas/peer-message-status.schema.json @@ -0,0 +1,74 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "control" + }, + "action": { + "type": "string", + "const": "peer_message_status" + }, + "status": { + "type": "string", + "enum": [ + "held", + "delivered", + "denied", + "expired", + "dropped" + ] + }, + "reason": { + "type": "string" + }, + "from": { + "type": "string", + "pattern": "^[A-Za-z0-9%:_/.\\\\-]{1,300}$" + }, + "orig_msg_id": { + "type": "string", + "minLength": 1 + }, + "status_detail": { + "type": "string" + }, + "drop_reason": { + "type": "string", + "enum": [ + "rate-limited", + "duplicate", + "hop-loop", + "hop-runaway", + "queue-full" + ] + }, + "dropped_msg_ids": { + "type": "array", + "items": { + "type": "string" + } + }, + "msgV": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "msg_id": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "type", + "action", + "status", + "reason", + "from", + "orig_msg_id", + "msgV", + "msg_id" + ], + "additionalProperties": false +} diff --git a/schemas/placeholder.schema.json b/schemas/placeholder.schema.json deleted file mode 100644 index 4c20e2b..0000000 --- a/schemas/placeholder.schema.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object" -} diff --git a/schemas/registry-entry.schema.json b/schemas/registry-entry.schema.json new file mode 100644 index 0000000..659c3ea --- /dev/null +++ b/schemas/registry-entry.schema.json @@ -0,0 +1,124 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "pid": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "sessionId": { + "type": "string", + "minLength": 1 + }, + "cwd": { + "type": "string" + }, + "startedAt": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "procStart": { + "type": "string", + "minLength": 1 + }, + "version": { + "type": "string", + "minLength": 1 + }, + "peerProtocol": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "peerFeatures": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "notify_idle", + "reply_across_default_dirs", + "artifact_yield" + ] + } + }, + "kind": { + "type": "string", + "enum": [ + "interactive", + "bg", + "daemon", + "daemon-worker" + ] + }, + "entrypoint": { + "type": "string" + }, + "pidDomain": { + "type": "string", + "minLength": 1 + }, + "messagingSocketPath": { + "type": "string", + "minLength": 1 + }, + "name": { + "type": "string" + }, + "nameSource": { + "type": "string", + "enum": [ + "user", + "peer", + "derived", + "collision", + "auto", + "hook" + ] + }, + "nameSince": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "updatedAt": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "status": { + "type": "string", + "enum": [ + "busy", + "shell", + "idle", + "waiting" + ] + }, + "statusUpdatedAt": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "bridgeSessionId": { + "type": "string" + } + }, + "required": [ + "pid", + "sessionId", + "cwd", + "startedAt", + "procStart", + "version", + "peerProtocol", + "peerFeatures", + "kind", + "entrypoint", + "pidDomain", + "messagingSocketPath", + "updatedAt" + ], + "additionalProperties": false +} diff --git a/schemas/unyield-artifact-replies.schema.json b/schemas/unyield-artifact-replies.schema.json new file mode 100644 index 0000000..9e40417 --- /dev/null +++ b/schemas/unyield-artifact-replies.schema.json @@ -0,0 +1,50 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "control" + }, + "action": { + "type": "string", + "const": "unyield_artifact_replies" + }, + "orig_msg_id": { + "type": "string", + "maxLength": 128 + }, + "slugs": { + "maxItems": 16, + "type": "array", + "items": { + "type": "string", + "maxLength": 128 + } + }, + "stopped": { + "type": "boolean" + }, + "msgV": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "msg_id": { + "type": "string", + "minLength": 1 + }, + "from": { + "type": "string" + } + }, + "required": [ + "type", + "action", + "orig_msg_id", + "slugs", + "msgV", + "msg_id" + ], + "additionalProperties": false +} diff --git a/schemas/user-frame.schema.json b/schemas/user-frame.schema.json new file mode 100644 index 0000000..328a410 --- /dev/null +++ b/schemas/user-frame.schema.json @@ -0,0 +1,95 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "msgV": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "msg_id": { + "type": "string", + "minLength": 1 + }, + "type": { + "type": "string", + "const": "user" + }, + "message": { + "type": "object", + "properties": { + "role": { + "type": "string", + "const": "user" + }, + "content": { + "type": "string" + } + }, + "required": [ + "role", + "content" + ], + "additionalProperties": false + }, + "priority": { + "type": "string", + "enum": [ + "next", + "later" + ] + }, + "from": { + "type": "string", + "pattern": "^[A-Za-z0-9%:_/.\\\\-]{1,300}$" + }, + "session_id": { + "type": "string" + }, + "file_attachments": { + "maxItems": 16, + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string", + "minLength": 1 + }, + "file_name": { + "type": "string", + "minLength": 1 + }, + "file_size": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "media_type": { + "type": "string" + } + }, + "required": [ + "path", + "file_name", + "file_size", + "sha256" + ], + "additionalProperties": false + } + } + }, + "required": [ + "msgV", + "msg_id", + "type", + "message", + "priority", + "from" + ], + "additionalProperties": false +} diff --git a/schemas/yield-artifact-replies.schema.json b/schemas/yield-artifact-replies.schema.json new file mode 100644 index 0000000..870449a --- /dev/null +++ b/schemas/yield-artifact-replies.schema.json @@ -0,0 +1,78 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "control" + }, + "action": { + "type": "string", + "const": "yield_artifact_replies" + }, + "from": { + "type": "string", + "maxLength": 512 + }, + "msg_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "session_id": { + "type": "string", + "maxLength": 512 + }, + "slugs": { + "maxItems": 16, + "type": "array", + "items": { + "type": "string", + "maxLength": 128 + } + }, + "reason": { + "default": "claim", + "type": "string", + "enum": [ + "resume", + "claim" + ] + }, + "sent_at": { + "type": "number" + }, + "claimed_at": { + "type": "number" + }, + "requester": { + "type": "object", + "properties": { + "cwd": { + "type": "string" + }, + "tmux": { + "type": "string" + } + }, + "additionalProperties": false + }, + "msgV": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "type", + "action", + "from", + "msg_id", + "session_id", + "slugs", + "reason", + "sent_at", + "msgV" + ], + "additionalProperties": false +} diff --git a/scripts/generate-json-schema.ts b/scripts/generate-json-schema.ts index 36b79bf..729f594 100644 --- a/scripts/generate-json-schema.ts +++ b/scripts/generate-json-schema.ts @@ -1,22 +1,45 @@ import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; +import { z } from "zod"; +import { PeerKeyFileSchema } from "../src/schemas/keyfile.js"; +import { RegistryEntrySchema } from "../src/schemas/registry.js"; +import { EnvelopeAttributesSchema } from "../src/schemas/envelope.js"; +import { + AuthLineSchema, + UserFrameSchema, + PeerMessageStatusSchema, + NotifyWhenIdleSchema, + PeerIdleNoticeSchema, + YieldArtifactRepliesSchema, + ArtifactRepliesYieldedSchema, + UnyieldArtifactRepliesSchema, +} from "../src/schemas/wire.js"; const OUT_DIR = "schemas"; -const schemas: Record = { - // Placeholder until milestone 2 wires the Zod schemas in. - placeholder: { - $schema: "https://json-schema.org/draft/2020-12/schema", - type: "object", - }, +const schemas: Record = { + "peer-key-file": PeerKeyFileSchema, + "registry-entry": RegistryEntrySchema, + "envelope-attributes": EnvelopeAttributesSchema, + "auth-line": AuthLineSchema, + "user-frame": UserFrameSchema, + "peer-message-status": PeerMessageStatusSchema, + "notify-when-idle": NotifyWhenIdleSchema, + "peer-idle-notice": PeerIdleNoticeSchema, + "yield-artifact-replies": YieldArtifactRepliesSchema, + "artifact-replies-yielded": ArtifactRepliesYieldedSchema, + "unyield-artifact-replies": UnyieldArtifactRepliesSchema, }; mkdirSync(OUT_DIR, { recursive: true }); +let emitted = 0; for (const [name, schema] of Object.entries(schemas)) { + const json = z.toJSONSchema(schema, { target: "draft-2020-12" }); + // Generation must be lossless: every emitted schema must re-validate the shape it came from (catches constraints Zod cannot express in JSON Schema). writeFileSync( join(OUT_DIR, `${name}.schema.json`), - `${JSON.stringify(schema, null, 2)}\n`, + `${JSON.stringify(json, null, 2)}\n`, ); + emitted += 1; } -const count = String(Object.keys(schemas).length); -console.log("wrote " + count + " schema(s) to " + OUT_DIR + "/"); +console.log("wrote " + String(emitted) + " schema(s) to " + OUT_DIR + "/"); diff --git a/src/domain/envelope.test.ts b/src/domain/envelope.test.ts new file mode 100644 index 0000000..af813c6 --- /dev/null +++ b/src/domain/envelope.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, test } from "vitest"; +import { + buildEnvelope, + parseEnvelope, + assertRoundTrips, + escapeBody, +} from "./envelope.js"; + +/** Captured verbatim from live Claude Code 2.1.269 traffic. */ +const REAL_FRAME_CONTENT = + '\nAcknowledged — this is a real Claude session replying to your control-frame experiment.\n'; + +describe("buildEnvelope", () => { + test("emits canonical attribute order", () => { + const env = buildEnvelope( + { + from: "uds:/tmp/cc-socks/123.sock", + fromSession: "6eeba7a7-72d7-486c-ab45-21fda7addd9c", + hopChain: ["21cc6f3d5c60ce84a36b2054"], + fromName: "raw-socket-peer", + fromMode: "bypass", + }, + "hello", + ); + const order = env + .slice(0, env.indexOf(">")) + .match(/[a-z-]+="/g) + ?.map((a) => a.slice(0, -2)); + expect(order).toEqual([ + "from", + "from-session", + "hop-chain", + "from-name", + "from-mode", + ]); + }); + + test("round-trips a real captured frame", () => { + expect(assertRoundTrips(REAL_FRAME_CONTENT)).toBe(true); + }); + + test("parses the real captured frame into its parts", () => { + const parsed = parseEnvelope(REAL_FRAME_CONTENT); + expect(parsed?.from).toBe("uds:/tmp/cc-socks/81322.sock"); + expect(parsed?.hopChain).toEqual(["21cc6f3d5c60ce84a36b2054"]); + expect(parsed?.fromName).toBe("agent-comms-06"); + expect(parsed?.fromMode).toBe("bypass"); + expect(parsed?.body).toContain("real Claude session replying"); + }); + + test("escapes closing tags in bodies, idempotently", () => { + const body = "says then more"; + const once = escapeBody(body); + expect(once).not.toContain(""); + expect(escapeBody(once)).toBe(once); + const env = buildEnvelope({ from: "uds:/tmp/x.sock" }, body); + expect(assertRoundTrips(env)).toBe(true); + }); + + test("rejects non-canonical attribute order on parse", () => { + const wrongOrder = + '\nbody\n'; + expect(parseEnvelope(wrongOrder)).toBeUndefined(); + }); + + test("rejects an over-long hop chain at the grammar level", () => { + const chain = Array.from({ length: 33 }, (_, i) => + i.toString(16).padStart(24, "0"), + ); + const env = + '\nbody\n'; + expect(parseEnvelope(env)).toBeUndefined(); + }); + + test("validates attributes against the schema", () => { + expect(() => buildEnvelope({ from: "not an address!" }, "x")).toThrow(); + }); +}); diff --git a/src/domain/envelope.ts b/src/domain/envelope.ts new file mode 100644 index 0000000..3e4e8fd --- /dev/null +++ b/src/domain/envelope.ts @@ -0,0 +1,97 @@ +import type { EnvelopeAttributes } from "../schemas/envelope.js"; +import { EnvelopeAttributesSchema } from "../schemas/envelope.js"; +import { + count, + HOP_ID_HEX_LENGTH, + MAX_ADDRESS_CHARS, + MAX_FROM_NAME_CHARS, + MAX_HOP_CHAIN_ENTRIES, + MAX_SESSION_REF_CHARS, +} from "../schemas/limits.js"; + +const TAG = "cross-session-message"; +const ONE_FEWER_THAN_CHAIN_MAX = MAX_HOP_CHAIN_ENTRIES - 1; + +/** + * The receiver's own parse shape: attributes in canonical order, each value constrained to its grammar, body between newlines. Mirrors `HB` in the reference client, including the round-trip check (rebuild-and-compare). The body is captured verbatim: escaping is applied on build only and is idempotent, so parsed bodies stay in escaped form exactly as sent. + */ +const ENVELOPE_RE = new RegExp( + `^<${TAG}(?: from="([A-Za-z0-9%:_/.\\\\-]{1,${count(MAX_ADDRESS_CHARS)}})")?` + + `(?: from-session="([A-Za-z0-9_-]{1,${count(MAX_SESSION_REF_CHARS)}})")?` + + `(?: hop-chain="([0-9a-f]{${count(HOP_ID_HEX_LENGTH)}}(?:,[0-9a-f]{${count(HOP_ID_HEX_LENGTH)}}){0,${count(ONE_FEWER_THAN_CHAIN_MAX)}})")?` + + `(?: from-name="([^"<>\\n\\r]{1,${count(MAX_FROM_NAME_CHARS)}})")?` + + `(?: from-mode="(bypass|prompting)")?` + + `>\\n([\\s\\S]*)\\n$`, +); + +/** Occurrences of the closing tag inside a body are escaped to a literal `<\`. Idempotent. */ +export function escapeBody(body: string): string { + return body.replaceAll(` 0) { + parts.push(` hop-chain="${attrs.hopChain.join(",")}"`); + } + if (attrs.fromName !== undefined) { + parts.push(` from-name="${attrs.fromName.replaceAll('"', "")}"`); + } + if (attrs.fromMode !== undefined) { + parts.push(` from-mode="${attrs.fromMode}"`); + } + return parts.join(""); +} + +export function buildEnvelope(attrs: EnvelopeAttributes, body: string): string { + const validated = EnvelopeAttributesSchema.parse(attrs); + return `<${TAG}${serializeAttributes(validated)}>\n${escapeBody(body)}\n`; +} + +export interface ParsedEnvelope { + from?: string; + fromSession?: string; + hopChain?: string[]; + fromName?: string; + fromMode?: "bypass" | "prompting"; + /** Verbatim body in escaped form, exactly as the receiver would see it. */ + body: string; +} + +export function parseEnvelope(content: string): ParsedEnvelope | undefined { + const match = ENVELOPE_RE.exec(content); + if (match === null) return undefined; + const parsed: ParsedEnvelope = { body: match[6] ?? "" }; + if (match[1] !== undefined) parsed.from = match[1]; + if (match[2] !== undefined) parsed.fromSession = match[2]; + if (match[3] !== undefined) parsed.hopChain = match[3].split(","); + if (match[4] !== undefined) parsed.fromName = match[4]; + if (match[5] === "bypass" || match[5] === "prompting") { + parsed.fromMode = match[5]; + } + return parsed; +} + +/** + * The receiver round-trips envelopes by rebuilding them from the parsed form and comparing; an envelope that fails this is treated as unparseable. Our builders must satisfy the same invariant. Escaping is idempotent, so rebuilding from an escaped body reproduces it byte-for-byte. + */ +export function assertRoundTrips(content: string): boolean { + const parsed = parseEnvelope(content); + if (parsed === undefined) return false; + const rebuilt = buildEnvelope( + { + from: parsed.from ?? "", + fromSession: parsed.fromSession, + hopChain: parsed.hopChain, + fromName: parsed.fromName, + fromMode: parsed.fromMode, + }, + parsed.body, + ); + return rebuilt === content; +} diff --git a/src/domain/hop-chain.test.ts b/src/domain/hop-chain.test.ts new file mode 100644 index 0000000..fd31dfe --- /dev/null +++ b/src/domain/hop-chain.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from "vitest"; +import { + appendHop, + checkChain, + isHopId, + parseChain, + MAX_CHAIN_LENGTH_GUARD, + MAX_SELF_HOPS, +} from "./hop-chain.js"; + +const id = (n: number) => n.toString(16).padStart(24, "0"); +const ownToken = "21cc6f3d5c60ce84a36b2054"; + +describe("hop-chain", () => { + test("recognises 24-hex ids", () => { + expect(isHopId(ownToken)).toBe(true); + expect(isHopId("short")).toBe(false); + expect(isHopId("Z".repeat(24))).toBe(false); + }); + + test("appendHop trims to the grammar maximum", () => { + const chain = Array.from({ length: 32 }, (_, i) => id(i + 1)); + const next = appendHop(chain, ownToken); + expect(next).toHaveLength(32); + expect(next.at(-1)).toBe(ownToken); + expect(next[0]).toBe(id(2)); + }); + + test("runaway fires above the guard length but not at it", () => { + const at = Array.from({ length: MAX_CHAIN_LENGTH_GUARD }, (_, i) => + id(i + 1), + ); + expect(checkChain(at, new Set()).admitted).toBe(true); + const over = [...at, id(999)]; + expect(checkChain(over, new Set())).toEqual({ + admitted: false, + reason: "hop-runaway", + }); + }); + + test("loop fires at maxSelfHops own-token occurrences, not below", () => { + const below = Array.from({ length: MAX_SELF_HOPS - 1 }, () => ownToken); + expect(checkChain(below, new Set([ownToken])).admitted).toBe(true); + const at = Array.from({ length: MAX_SELF_HOPS }, () => ownToken); + expect(checkChain(at, new Set([ownToken]))).toEqual({ + admitted: false, + reason: "hop-loop", + }); + }); + + test("a single own-token occurrence is harmless (verified protocol behaviour)", () => { + expect(checkChain([ownToken], new Set([ownToken])).admitted).toBe(true); + }); + + test("parseChain rejects malformed entries", () => { + expect(parseChain(`${ownToken},${ownToken}`)).toHaveLength(2); + expect(parseChain(`${ownToken},nothex`)).toBeUndefined(); + }); +}); diff --git a/src/domain/hop-chain.ts b/src/domain/hop-chain.ts new file mode 100644 index 0000000..fdc9d89 --- /dev/null +++ b/src/domain/hop-chain.ts @@ -0,0 +1,62 @@ +import { + count, + HOP_ID_HEX_LENGTH, + MAX_HOP_CHAIN_ENTRIES, +} from "../schemas/limits.js"; + +/** Receiver-side guard default: chains longer than this drop as hop-runaway. */ +export const MAX_CHAIN_LENGTH_GUARD = 28; +/** Receiver-side guard default: this many own-token occurrences drop as hop-loop. */ +export const MAX_SELF_HOPS = 10; + +const HOP_ID_RE = new RegExp(`^[0-9a-f]{${count(HOP_ID_HEX_LENGTH)}}$`); + +export function isHopId(value: string): boolean { + return HOP_ID_RE.test(value); +} + +export function joinChain(ids: readonly string[]): string | undefined { + return ids.length > 0 ? ids.join(",") : undefined; +} + +export function parseChain(serialized: string): string[] | undefined { + const parts = serialized.split(","); + return parts.every((p) => HOP_ID_RE.test(p)) ? parts : undefined; +} + +/** + * Append the relayer's own id, keeping at most the grammar-level maximum (the receiver trims to the same bound in `NDt`). + */ +export function appendHop( + chain: readonly string[] | undefined, + ownId: string, +): string[] { + const next = [...(chain ?? []), ownId]; + return next.length > MAX_HOP_CHAIN_ENTRIES + ? next.slice(next.length - MAX_HOP_CHAIN_ENTRIES) + : next; +} + +export interface ChainCheck { + admitted: boolean; + reason?: "hop-runaway" | "hop-loop"; +} + +/** Mirror the receiver's admission check: runaway on length, loop on own-token count. */ +export function checkChain( + chain: readonly string[] | undefined, + ownTokens: ReadonlySet, +): ChainCheck { + if (chain === undefined) return { admitted: true }; + if (chain.length > MAX_CHAIN_LENGTH_GUARD) { + return { admitted: false, reason: "hop-runaway" }; + } + let selfHops = 0; + for (const id of chain) { + if (ownTokens.has(id)) selfHops += 1; + } + if (selfHops >= MAX_SELF_HOPS) { + return { admitted: false, reason: "hop-loop" }; + } + return { admitted: true }; +} diff --git a/src/domain/ids.ts b/src/domain/ids.ts new file mode 100644 index 0000000..4aa6df3 --- /dev/null +++ b/src/domain/ids.ts @@ -0,0 +1,12 @@ +import { randomUUID, randomBytes } from "node:crypto"; +import { HOP_ID_BYTES } from "../schemas/limits.js"; + +/** Message ids are UUID v4, matching the reference client's `qM()`. */ +export function newMsgId(): string { + return randomUUID(); +} + +/** Hop-chain entries are 24-hex ids derived from 12 random bytes. */ +export function newHopId(): string { + return randomBytes(HOP_ID_BYTES).toString("hex"); +} diff --git a/src/schemas/define-schema.ts b/src/schemas/define-schema.ts new file mode 100644 index 0000000..11f8850 --- /dev/null +++ b/src/schemas/define-schema.ts @@ -0,0 +1,16 @@ +import type { z } from "zod"; + +/** + * Attach an `.is()` type guard to a Zod schema so schema, inferred type, and runtime guard derive from one definition (single source of truth). `Schema.parse()` at JSON boundaries; `Schema.is()` for narrowing. + */ +export function defineSchema(schema: T) { + /* Object.assign is required over spread here: a Zod schema is a class + instance, and spreading it would drop the prototype (parse, safeParse, + refinements), so we deliberately attach the guard to the live instance. */ + // eslint-disable-next-line exadev/no-object-assign + return Object.assign(schema, { + is(value: unknown): value is z.infer { + return schema.safeParse(value).success; + }, + }); +} diff --git a/src/schemas/envelope.ts b/src/schemas/envelope.ts new file mode 100644 index 0000000..5516307 --- /dev/null +++ b/src/schemas/envelope.ts @@ -0,0 +1,45 @@ +import { z } from "zod"; +import { defineSchema } from "./define-schema.js"; +import { + HOP_ID_HEX_LENGTH, + MAX_ADDRESS_CHARS, + MAX_FROM_NAME_CHARS, + MAX_HOP_CHAIN_ENTRIES, + MAX_SESSION_REF_CHARS, + count, +} from "./limits.js"; + +/** + * Grammar of the envelope attributes, mirroring the receiver's own parser. The serialized attribute ORDER is canonical (from, from-session, hop-chain, from-name, from-mode): the receiver's regex matches that sequence only, so any other order parses as nothing. + */ +export const EnvelopeAddressSchema = defineSchema( + z + .string() + .regex( + new RegExp(`^[A-Za-z0-9%:_/.\\\\-]{1,${count(MAX_ADDRESS_CHARS)}}$`), + ), +); +export type EnvelopeAddress = z.infer; + +export const FromModeSchema = defineSchema(z.enum(["bypass", "prompting"])); +export type FromMode = z.infer; + +export const HopIdSchema = z + .string() + .regex(new RegExp(`^[0-9a-f]{${count(HOP_ID_HEX_LENGTH)}}$`)); +export type HopId = z.infer; + +export const EnvelopeAttributesSchema = defineSchema( + z.object({ + from: EnvelopeAddressSchema, + fromSession: z + .string() + .regex(new RegExp(`^[A-Za-z0-9_-]{1,${count(MAX_SESSION_REF_CHARS)}}$`)) + .optional(), + /** Parsed hop chain: at most 32 ids at the grammar level. */ + hopChain: z.array(HopIdSchema).max(MAX_HOP_CHAIN_ENTRIES).optional(), + fromName: z.string().min(1).max(MAX_FROM_NAME_CHARS).optional(), + fromMode: FromModeSchema.optional(), + }), +); +export type EnvelopeAttributes = z.infer; diff --git a/src/schemas/keyfile.ts b/src/schemas/keyfile.ts new file mode 100644 index 0000000..356359c --- /dev/null +++ b/src/schemas/keyfile.ts @@ -0,0 +1,16 @@ +import { z } from "zod"; +import { defineSchema } from "./define-schema.js"; +import { count, PEER_TOKEN_HEX_LENGTH } from "./limits.js"; + +/** The auth key a session publishes next to its socket. */ +export const PeerKeyFileSchema = defineSchema( + z.object({ + peerToken: z + .string() + .regex(new RegExp(`^[0-9a-f]{${count(PEER_TOKEN_HEX_LENGTH)}}$`)), + procStart: z.string().min(1), + pidDomain: z.string().min(1), + }), +); + +export type PeerKeyFile = z.infer; diff --git a/src/schemas/limits.ts b/src/schemas/limits.ts new file mode 100644 index 0000000..c2b2bf8 --- /dev/null +++ b/src/schemas/limits.ts @@ -0,0 +1,27 @@ +/** + * Protocol limits, each named after the rule it encodes. Values derive from the receiver's own grammar (verified against Claude Code 2.1.269): peer tokens are 16-byte hex, hop ids are 12-byte hex, the hop chain holds at most 32 ids at the grammar level with the runaway guard at 28, and the envelope attribute caps mirror the receiver's parser regexes. + */ +export const PEER_TOKEN_BYTES = 16; +export const HOP_ID_BYTES = 12; +export const SHA256_HEX_LENGTH = 64; +export const MAX_HOP_CHAIN_ENTRIES = 32; +export const MAX_ADDRESS_CHARS = 300; +export const MAX_SESSION_REF_CHARS = 80; +export const MAX_FROM_NAME_CHARS = 80; +export const MAX_ATTACHMENTS_PER_MESSAGE = 16; +export const MAX_YIELD_SLUGS = 16; +export const MAX_SLUG_CHARS = 128; +export const MAX_MSG_ID_CHARS = 128; +export const MAX_SESSION_ID_CHARS = 512; +export const MAX_ADDRESS_FIELD_CHARS = 512; + +const hex = (bytes: number) => bytes * 2; + +export const PEER_TOKEN_HEX_LENGTH = hex(PEER_TOKEN_BYTES); +export const HOP_ID_HEX_LENGTH = hex(HOP_ID_BYTES); + +/** + * Lint-clean string form of a numeric limit for regex interpolation; + * `restrict-template-expressions` rejects numbers inside template literals. + */ +export const count = (n: number): string => String(n); diff --git a/src/schemas/registry.ts b/src/schemas/registry.ts new file mode 100644 index 0000000..82187ca --- /dev/null +++ b/src/schemas/registry.ts @@ -0,0 +1,50 @@ +import { z } from "zod"; +import { defineSchema } from "./define-schema.js"; + +/** Whitelist the receiver applies on read; other values parse to undefined. */ +export const NameSourceSchema = defineSchema( + z.enum(["user", "peer", "derived", "collision", "auto", "hook"]), +); +export type NameSource = z.infer; + +export const PeerStatusSchema = defineSchema( + z.enum(["busy", "shell", "idle", "waiting"]), +); +export type PeerStatus = z.infer; + +export const SessionKindSchema = defineSchema( + z.enum(["interactive", "bg", "daemon", "daemon-worker"]), +); +export type SessionKind = z.infer; + +export const PeerFeatureSchema = defineSchema( + z.enum(["notify_idle", "reply_across_default_dirs", "artifact_yield"]), +); +export type PeerFeature = z.infer; + +/** One entry of the ~/.claude/sessions/.json registry. */ +export const RegistryEntrySchema = defineSchema( + z.object({ + pid: z.number().int().positive(), + sessionId: z.string().min(1), + cwd: z.string(), + startedAt: z.number().int().nonnegative(), + procStart: z.string().min(1), + version: z.string().min(1), + peerProtocol: z.number().int(), + peerFeatures: z.array(PeerFeatureSchema), + kind: SessionKindSchema, + entrypoint: z.string(), + pidDomain: z.string().min(1), + messagingSocketPath: z.string().min(1), + name: z.string().optional(), + nameSource: NameSourceSchema.optional(), + nameSince: z.number().int().nonnegative().optional(), + updatedAt: z.number().int().nonnegative(), + status: PeerStatusSchema.optional(), + statusUpdatedAt: z.number().int().nonnegative().optional(), + bridgeSessionId: z.string().optional(), + }), +); + +export type RegistryEntry = z.infer; diff --git a/src/schemas/wire.ts b/src/schemas/wire.ts new file mode 100644 index 0000000..206b817 --- /dev/null +++ b/src/schemas/wire.ts @@ -0,0 +1,200 @@ +import { z } from "zod"; +import { + MAX_ADDRESS_CHARS, + PEER_TOKEN_HEX_LENGTH, + SHA256_HEX_LENGTH, + MAX_ATTACHMENTS_PER_MESSAGE, + MAX_YIELD_SLUGS, + MAX_SLUG_CHARS, + MAX_MSG_ID_CHARS, + MAX_SESSION_ID_CHARS, + MAX_ADDRESS_FIELD_CHARS, + count, +} from "./limits.js"; +import { defineSchema } from "./define-schema.js"; + +/** Line 1 of every connection: the receiver's own peerToken (peer class) or childToken (self-sent class). */ +export const AuthLineSchema = defineSchema( + z.object({ + type: z.literal("auth"), + token: z + .string() + .regex(new RegExp(`^[0-9a-f]{${count(PEER_TOKEN_HEX_LENGTH)}}$`)), + }), +); +export type AuthLine = z.infer; + +export const PrioritySchema = defineSchema(z.enum(["next", "later"])); +export type Priority = z.infer; + +export const FileAttachmentSchema = defineSchema( + z.object({ + path: z.string().min(1), + file_name: z.string().min(1), + file_size: z.number().int().nonnegative(), + sha256: z + .string() + .regex(new RegExp(`^[0-9a-f]{${count(SHA256_HEX_LENGTH)}}$`)), + media_type: z.string().optional(), + }), +); +export type FileAttachment = z.infer; + +/** A user-turn message. `"type": "user"` is load-bearing: any other value is silently dropped. */ +export const UserFrameSchema = defineSchema( + z.object({ + msgV: z.number().int(), + msg_id: z.string().min(1), + type: z.literal("user"), + message: z.object({ + role: z.literal("user"), + content: z.string(), + }), + priority: PrioritySchema, + from: z + .string() + .regex( + new RegExp(`^[A-Za-z0-9%:_/.\\\\-]{1,${count(MAX_ADDRESS_CHARS)}}$`), + ), + /** When present, must match the receiver's sessionId or the frame is silently dropped. */ + session_id: z.string().optional(), + file_attachments: z + .array(FileAttachmentSchema) + .max(MAX_ATTACHMENTS_PER_MESSAGE) + .optional(), + }), +); +export type UserFrame = z.infer; + +export const DropReasonSchema = defineSchema( + z.enum([ + "rate-limited", + "duplicate", + "hop-loop", + "hop-runaway", + "queue-full", + ]), +); +export type DropReason = z.infer; + +export const PeerMessageStatusSchema = defineSchema( + z.object({ + type: z.literal("control"), + action: z.literal("peer_message_status"), + status: z.enum(["held", "delivered", "denied", "expired", "dropped"]), + reason: z.string(), + from: z + .string() + .regex( + new RegExp(`^[A-Za-z0-9%:_/.\\\\-]{1,${count(MAX_ADDRESS_CHARS)}}$`), + ), + orig_msg_id: z.string().min(1), + status_detail: z.string().optional(), + drop_reason: DropReasonSchema.optional(), + dropped_msg_ids: z.array(z.string()).optional(), + msgV: z.number().int(), + msg_id: z.string().min(1), + }), +); +export type PeerMessageStatus = z.infer; + +export const NotifyWhenIdleSchema = defineSchema( + z.object({ + type: z.literal("control"), + action: z.literal("notify_when_idle"), + from: z + .string() + .regex( + new RegExp(`^[A-Za-z0-9%:_/.\\\\-]{1,${count(MAX_ADDRESS_CHARS)}}$`), + ), + from_mode: z.enum(["bypass", "prompting"]).optional(), + msgV: z.number().int(), + msg_id: z.string().min(1), + }), +); +export type NotifyWhenIdle = z.infer; + +export const PeerIdleNoticeSchema = defineSchema( + z.object({ + type: z.literal("control"), + action: z.literal("peer_idle_notice"), + orig_msg_id: z.string().min(1), + state: z.enum(["idle", "exited"]), + finished_at: z.number(), + detail: z.string().optional(), + from: z + .string() + .regex( + new RegExp(`^[A-Za-z0-9%:_/.\\\\-]{1,${count(MAX_ADDRESS_CHARS)}}$`), + ), + from_mode: z.enum(["bypass", "prompting"]).optional(), + msgV: z.number().int(), + msg_id: z.string().min(1), + }), +); +export type PeerIdleNotice = z.infer; + +export const YieldArtifactRepliesSchema = defineSchema( + z.object({ + type: z.literal("control"), + action: z.literal("yield_artifact_replies"), + from: z.string().max(MAX_ADDRESS_FIELD_CHARS), + msg_id: z.string().min(1).max(MAX_MSG_ID_CHARS), + session_id: z.string().max(MAX_SESSION_ID_CHARS), + slugs: z.array(z.string().max(MAX_SLUG_CHARS)).max(MAX_YIELD_SLUGS), + reason: z.enum(["resume", "claim"]).catch("claim"), + sent_at: z.number(), + claimed_at: z.number().optional(), + requester: z + .object({ cwd: z.string().optional(), tmux: z.string().optional() }) + .optional(), + msgV: z.number().int(), + }), +); +export type YieldArtifactReplies = z.infer; + +export const ArtifactRepliesYieldedSchema = defineSchema( + z.object({ + type: z.literal("control"), + action: z.literal("artifact_replies_yielded"), + orig_msg_id: z.string().max(MAX_MSG_ID_CHARS), + yielded: z.string().optional(), + not_held: z.string().optional(), + refused: z.string().optional(), + msgV: z.number().int(), + msg_id: z.string().min(1), + from: z.string().optional(), + }), +); +export type ArtifactRepliesYielded = z.infer< + typeof ArtifactRepliesYieldedSchema +>; + +export const UnyieldArtifactRepliesSchema = defineSchema( + z.object({ + type: z.literal("control"), + action: z.literal("unyield_artifact_replies"), + orig_msg_id: z.string().max(MAX_MSG_ID_CHARS), + slugs: z.array(z.string().max(MAX_SLUG_CHARS)).max(MAX_YIELD_SLUGS), + stopped: z.boolean().optional(), + msgV: z.number().int(), + msg_id: z.string().min(1), + from: z.string().optional(), + }), +); +export type UnyieldArtifactReplies = z.infer< + typeof UnyieldArtifactRepliesSchema +>; + +export const ControlFrameSchema = z.union([ + PeerMessageStatusSchema, + NotifyWhenIdleSchema, + PeerIdleNoticeSchema, + YieldArtifactRepliesSchema, + ArtifactRepliesYieldedSchema, + UnyieldArtifactRepliesSchema, +]); +export type ControlFrame = z.infer; + +export const WireFrameSchema = z.union([UserFrameSchema, ControlFrameSchema]); +export type WireFrame = z.infer;