From 08a932708849e509c77772f9330ecf6d30bfa3ae Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 20:19:03 +0100 Subject: [PATCH 1/3] fix(release): reference analyzer and generator by package name pnpm's strict node_modules layout does not expose semantic-release's built-in plugin subpaths (semantic-release/commit-analyzer and semantic-release/release-notes-generator), so the release job failed with MODULE_NOT_FOUND. Install both plugins as devDependencies and reference them by package name instead. --- package.json | 2 ++ pnpm-lock.yaml | 6 ++++++ release.config.ts | 4 ++-- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index f8d7cff..db4754f 100644 --- a/package.json +++ b/package.json @@ -80,10 +80,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..fc7e9f8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,6 +33,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 +48,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) 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" }], From eebd66364b584a57a8d5046cbbeedc60e197fa86 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 20:31:29 +0100 Subject: [PATCH 2/3] feat(schemas): encode the wire protocol as Zod single-source-of-truth Every verified protocol shape becomes a schema with an attached .is() guard (defineSchema): key files, registry entries, the envelope attribute grammar with its canonical order and charsets, auth lines, user frames with file attachments, and all control frames (peer_message_status receipts with the drop taxonomy, notify_when_idle / peer_idle_notice, the yield_artifact_replies family). Protocol limits live in limits.ts as named constants derived from the receiver's own grammar. Domain layer: envelope builder/parser mirroring the receiver's regex including the rebuild-and-compare round-trip invariant (bodies stay in escaped form; escaping is idempotent), hop-chain utilities replicating the admission guards (runaway > 28, loop at >= 10 own-token hits), and id generation. The envelope round-trip test fixture is a frame captured verbatim from live Claude Code 2.1.269 traffic. The JSON-Schema generator now emits draft-2020-12 documents for all eleven schemas, published as ./schemas/*.schema.json subpath exports. --- eslint.config.ts | 12 ++ package.json | 3 +- pnpm-lock.yaml | 82 +++----- schemas/artifact-replies-yielded.schema.json | 47 +++++ schemas/auth-line.schema.json | 19 ++ schemas/envelope-attributes.schema.json | 38 ++++ schemas/notify-when-idle.schema.json | 42 ++++ schemas/peer-idle-notice.schema.json | 62 ++++++ schemas/peer-key-file.schema.json | 24 +++ schemas/peer-message-status.schema.json | 74 +++++++ schemas/placeholder.schema.json | 4 - schemas/registry-entry.schema.json | 124 ++++++++++++ schemas/unyield-artifact-replies.schema.json | 50 +++++ schemas/user-frame.schema.json | 95 +++++++++ schemas/yield-artifact-replies.schema.json | 78 ++++++++ scripts/generate-json-schema.ts | 41 +++- src/domain/envelope.test.ts | 80 ++++++++ src/domain/envelope.ts | 97 +++++++++ src/domain/hop-chain.test.ts | 59 ++++++ src/domain/hop-chain.ts | 62 ++++++ src/domain/ids.ts | 12 ++ src/schemas/define-schema.ts | 16 ++ src/schemas/envelope.ts | 45 +++++ src/schemas/keyfile.ts | 16 ++ src/schemas/limits.ts | 27 +++ src/schemas/registry.ts | 50 +++++ src/schemas/wire.ts | 200 +++++++++++++++++++ 27 files changed, 1389 insertions(+), 70 deletions(-) create mode 100644 schemas/artifact-replies-yielded.schema.json create mode 100644 schemas/auth-line.schema.json create mode 100644 schemas/envelope-attributes.schema.json create mode 100644 schemas/notify-when-idle.schema.json create mode 100644 schemas/peer-idle-notice.schema.json create mode 100644 schemas/peer-key-file.schema.json create mode 100644 schemas/peer-message-status.schema.json delete mode 100644 schemas/placeholder.schema.json create mode 100644 schemas/registry-entry.schema.json create mode 100644 schemas/unyield-artifact-replies.schema.json create mode 100644 schemas/user-frame.schema.json create mode 100644 schemas/yield-artifact-replies.schema.json create mode 100644 src/domain/envelope.test.ts create mode 100644 src/domain/envelope.ts create mode 100644 src/domain/hop-chain.test.ts create mode 100644 src/domain/hop-chain.ts create mode 100644 src/domain/ids.ts create mode 100644 src/schemas/define-schema.ts create mode 100644 src/schemas/envelope.ts create mode 100644 src/schemas/keyfile.ts create mode 100644 src/schemas/limits.ts create mode 100644 src/schemas/registry.ts create mode 100644 src/schemas/wire.ts 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 db4754f..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": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fc7e9f8..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: @@ -62,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) @@ -1706,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'} @@ -2123,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'} @@ -2589,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==} @@ -3050,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'} @@ -3314,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==} @@ -3499,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'} @@ -3884,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 @@ -3957,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 @@ -3993,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': {} @@ -4463,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 @@ -4548,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 @@ -5102,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' @@ -5136,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 @@ -5599,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: {} @@ -5978,8 +5957,6 @@ snapshots: natural-compare@1.4.0: {} - neo-async@2.6.2: {} - nerf-dart@1.0.0: {} node-emoji@2.2.0: @@ -6385,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: {} @@ -6628,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 @@ -6736,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/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; From 3b5f7d39f4da93543aa2eb2b812b2159eb41b4cc Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 20:32:11 +0100 Subject: [PATCH 3/3] docs: add implement-it-yourself protocol reference docs/PROTOCOL.md carries the full verified wire reference: transport and identity, key-file auth classes, user and control frames, envelope grammar, the consent model, peer registration and roster admission, the receipt status table with the drop taxonomy, idle subscriptions, guard rails with hop-token derivation, artifact_yield, file transfer up to its upstream flag, cloud/bridge routing, and the daemon - with verification status separating live-confirmed behaviour from evidenced boundaries. --- docs/PROTOCOL.md | 193 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 docs/PROTOCOL.md 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.