agent-comms 8.15.8 → 8.15.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (184) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/dist/bridges/user/web/server.d.ts.map +1 -1
  4. package/dist/bridges/user/web/server.js +44 -24
  5. package/dist/bridges/user/web/server.js.map +1 -1
  6. package/dist/bridges/user/web/test/web-assets.unit.test.d.ts +5 -0
  7. package/dist/bridges/user/web/test/web-assets.unit.test.d.ts.map +1 -0
  8. package/dist/bridges/user/web/test/web-assets.unit.test.js +90 -0
  9. package/dist/bridges/user/web/test/web-assets.unit.test.js.map +1 -0
  10. package/dist/bridges/user/web/test/web-server-unbuilt-frontend.integration.test.d.ts +2 -0
  11. package/dist/bridges/user/web/test/web-server-unbuilt-frontend.integration.test.d.ts.map +1 -0
  12. package/dist/bridges/user/web/test/web-server-unbuilt-frontend.integration.test.js +75 -0
  13. package/dist/bridges/user/web/test/web-server-unbuilt-frontend.integration.test.js.map +1 -0
  14. package/dist/bridges/user/web/web-assets.d.ts +28 -0
  15. package/dist/bridges/user/web/web-assets.d.ts.map +1 -0
  16. package/dist/bridges/user/web/web-assets.js +67 -0
  17. package/dist/bridges/user/web/web-assets.js.map +1 -0
  18. package/package.json +8 -2
  19. package/src/account-cli.ts +195 -0
  20. package/src/account-join-cli.ts +191 -0
  21. package/src/bridges/cc-peer/approval-commands.ts +82 -0
  22. package/src/bridges/cc-peer/bridge.ts +138 -0
  23. package/src/bridges/cc-peer/default-front.ts +58 -0
  24. package/src/bridges/cc-peer/front-controller.ts +160 -0
  25. package/src/bridges/cc-peer/front-relay.ts +191 -0
  26. package/src/bridges/cc-peer/front-runtime.ts +167 -0
  27. package/src/bridges/cc-peer/front.ts +59 -0
  28. package/src/bridges/cc-peer/reply-aliases.ts +90 -0
  29. package/src/bridges/cc-peer/run.ts +100 -0
  30. package/src/bridges/cc-peer/shared-peer.ts +28 -0
  31. package/src/bridges/claude-code/channel.ts +318 -0
  32. package/src/bridges/codex/tool.ts +132 -0
  33. package/src/bridges/mcp/server.ts +137 -0
  34. package/src/bridges/opencode/plugin.ts +130 -0
  35. package/src/bridges/pi/extension.ts +330 -0
  36. package/src/bridges/registry.ts +17 -0
  37. package/src/bridges/user/cli.ts +87 -0
  38. package/src/bridges/user/controller.ts +345 -0
  39. package/src/bridges/user/tui.ts +300 -0
  40. package/src/bridges/user/web/actions.ts +173 -0
  41. package/src/bridges/user/web/contract.ts +222 -0
  42. package/src/bridges/user/web/dist/bundle.js +49 -0
  43. package/src/bridges/user/web/dist/icons/icon-192x192.svg +9 -0
  44. package/src/bridges/user/web/dist/icons/icon-512x512.svg +9 -0
  45. package/src/bridges/user/web/dist/icons/icon-96x96.svg +9 -0
  46. package/src/bridges/user/web/dist/index.html +21 -0
  47. package/src/bridges/user/web/dist/manifest.json +26 -0
  48. package/src/bridges/user/web/dist/mesh-worker.js +64 -0
  49. package/src/bridges/user/web/dist/sw.js +1 -0
  50. package/src/bridges/user/web/event-publisher.ts +114 -0
  51. package/src/bridges/user/web/port-discovery.ts +49 -0
  52. package/src/bridges/user/web/reads.ts +35 -0
  53. package/src/bridges/user/web/router.ts +158 -0
  54. package/src/bridges/user/web/server.ts +782 -0
  55. package/src/bridges/user/web/vite.config.ts +71 -0
  56. package/src/bridges/user/web/web-access.ts +404 -0
  57. package/src/bridges/user/web/web-assets.ts +90 -0
  58. package/src/bridges/user/web/web-beacon.ts +69 -0
  59. package/src/bridges/user/web/web-console-static.ts +90 -0
  60. package/src/cli-main.ts +635 -0
  61. package/src/cli.ts +13 -0
  62. package/src/core/account-bundle.ts +264 -0
  63. package/src/core/account-join.ts +289 -0
  64. package/src/core/account-ledger-crypto.ts +154 -0
  65. package/src/core/account-ledger-store.ts +66 -0
  66. package/src/core/account-ledger.ts +385 -0
  67. package/src/core/agent-details.ts +216 -0
  68. package/src/core/agent-list-format.ts +97 -0
  69. package/src/core/agent-registry.ts +282 -0
  70. package/src/core/agent-table.ts +58 -0
  71. package/src/core/atomic-file.ts +46 -0
  72. package/src/core/bind-retry.ts +38 -0
  73. package/src/core/bridge-mesh.ts +176 -0
  74. package/src/core/bridge.ts +718 -0
  75. package/src/core/capability-ask.ts +162 -0
  76. package/src/core/capability-bubble-up.ts +237 -0
  77. package/src/core/comms-store.ts +130 -0
  78. package/src/core/connection-approval.ts +134 -0
  79. package/src/core/connection-code-tool.ts +92 -0
  80. package/src/core/connection-code.ts +296 -0
  81. package/src/core/coordinator-role.ts +224 -0
  82. package/src/core/data-directory.ts +32 -0
  83. package/src/core/data-frame-routing.ts +309 -0
  84. package/src/core/default-command.ts +12 -0
  85. package/src/core/delegation-policy.ts +41 -0
  86. package/src/core/delivery-engine.ts +649 -0
  87. package/src/core/device-membership-verification.ts +125 -0
  88. package/src/core/device-membership.ts +130 -0
  89. package/src/core/directory-admission.ts +155 -0
  90. package/src/core/discovery-mdns.ts +247 -0
  91. package/src/core/discovery-tailscale.ts +224 -0
  92. package/src/core/discovery.ts +303 -0
  93. package/src/core/display-name.ts +73 -0
  94. package/src/core/dm-grant-actions.ts +72 -0
  95. package/src/core/dm-send-delegation.ts +73 -0
  96. package/src/core/dm-token-verification.ts +76 -0
  97. package/src/core/election-sessions.ts +135 -0
  98. package/src/core/error-reporting.ts +85 -0
  99. package/src/core/first-contact.ts +242 -0
  100. package/src/core/gateway-trust-actions.ts +184 -0
  101. package/src/core/gateway-trust.ts +305 -0
  102. package/src/core/gossip-directory.ts +276 -0
  103. package/src/core/gossip-extensions.ts +123 -0
  104. package/src/core/group-proofs.ts +205 -0
  105. package/src/core/handshake.ts +354 -0
  106. package/src/core/hub-forwarding.ts +24 -0
  107. package/src/core/hub-link.ts +176 -0
  108. package/src/core/hub-session.ts +374 -0
  109. package/src/core/identity-store.ts +715 -0
  110. package/src/core/identity.ts +489 -0
  111. package/src/core/index.ts +65 -0
  112. package/src/core/issuer-grouping.ts +165 -0
  113. package/src/core/issuer-identity-file.ts +187 -0
  114. package/src/core/lan-web-actions.ts +34 -0
  115. package/src/core/lan-web-uis.ts +55 -0
  116. package/src/core/ledger-lock.ts +81 -0
  117. package/src/core/listener-registry.ts +133 -0
  118. package/src/core/loopback-address.ts +22 -0
  119. package/src/core/machine-identity.ts +77 -0
  120. package/src/core/machine-list-groups.ts +53 -0
  121. package/src/core/mcp-registry-retry.ts +69 -0
  122. package/src/core/mcp-registry-version.ts +18 -0
  123. package/src/core/membership-proof.ts +139 -0
  124. package/src/core/membership-proofs.ts +149 -0
  125. package/src/core/mesh-graph-trace-tool.ts +81 -0
  126. package/src/core/mesh-graph.ts +89 -0
  127. package/src/core/mesh-network-actions.ts +183 -0
  128. package/src/core/mesh-store-options.ts +23 -0
  129. package/src/core/mesh-store-shared.ts +134 -0
  130. package/src/core/mesh-store.ts +1114 -0
  131. package/src/core/name-claim.ts +145 -0
  132. package/src/core/naming-actions.ts +90 -0
  133. package/src/core/naming.ts +138 -0
  134. package/src/core/nanoid.ts +20 -0
  135. package/src/core/network-interfaces.ts +24 -0
  136. package/src/core/package-version.ts +34 -0
  137. package/src/core/peer-lifecycle.ts +377 -0
  138. package/src/core/peer-link-error.ts +41 -0
  139. package/src/core/peer-versions.ts +98 -0
  140. package/src/core/pending-connection.ts +53 -0
  141. package/src/core/petnames.ts +121 -0
  142. package/src/core/pgp-keyserver.ts +32 -0
  143. package/src/core/pid-lock-file.ts +122 -0
  144. package/src/core/push-manager.ts +76 -0
  145. package/src/core/random-id.ts +9 -0
  146. package/src/core/receiver-set.ts +42 -0
  147. package/src/core/relay-role.ts +182 -0
  148. package/src/core/relay-selection.ts +120 -0
  149. package/src/core/relay-server.ts +198 -0
  150. package/src/core/relay-uplink.ts +78 -0
  151. package/src/core/request-timeouts.ts +37 -0
  152. package/src/core/room-discovery.ts +48 -0
  153. package/src/core/room-lifecycle.ts +838 -0
  154. package/src/core/room-lookup.ts +41 -0
  155. package/src/core/room-messaging.ts +247 -0
  156. package/src/core/room-notice-log.ts +61 -0
  157. package/src/core/room-path.ts +84 -0
  158. package/src/core/room-protocol.ts +893 -0
  159. package/src/core/room-router.ts +180 -0
  160. package/src/core/room-token-verification.ts +85 -0
  161. package/src/core/room-wire-extensions.ts +60 -0
  162. package/src/core/self-name-claim.ts +67 -0
  163. package/src/core/send-outcome.ts +69 -0
  164. package/src/core/shutdown-signals.ts +57 -0
  165. package/src/core/stale-agent-checker.ts +108 -0
  166. package/src/core/store.ts +704 -0
  167. package/src/core/token-text.ts +40 -0
  168. package/src/core/tool.ts +853 -0
  169. package/src/core/transport-events.ts +73 -0
  170. package/src/core/transport-queries.ts +54 -0
  171. package/src/core/transport.ts +367 -0
  172. package/src/core/types.ts +550 -0
  173. package/src/core/user-identity.ts +290 -0
  174. package/src/core/vapid.ts +81 -0
  175. package/src/core/version-check.ts +147 -0
  176. package/src/core/version-report-actions.ts +79 -0
  177. package/src/core/web-beacon.ts +31 -0
  178. package/src/core/web-push.ts +322 -0
  179. package/src/core/wire-mesh-core-version.ts +25 -0
  180. package/src/core/wire-mesh-identity.ts +36 -0
  181. package/src/core/wire-mesh-transport-options.ts +82 -0
  182. package/src/core/wire-mesh-transport.ts +1007 -0
  183. package/src/core/wire-protocol.ts +153 -0
  184. package/src/core/ws-dial.ts +191 -0
@@ -0,0 +1,195 @@
1
+ /**
2
+ * `agent-comms account <export|import|invite|join>` (agent-comms#344): the deliberate, consented ways the account key that lets one user principal span machines leaves or reaches a machine. Export and import carry the key as a passphrase-sealed bundle (account-bundle.ts), written to a new owner-only file and never printed; the passphrase comes from AGENT_COMMS_ACCOUNT_PASSPHRASE when set, else from a prompt that does not echo. Invite and join carry it over the mesh (account-join-cli.ts). All four are commands a person runs, never agent_comms tool actions, so nothing an agent is told can move the account and no key or invite lands in an agent's transcript.
3
+ */
4
+
5
+ import * as fs from "node:fs";
6
+ import { deviceIdToHex } from "wire-mesh-core/domain/device-id";
7
+ import {
8
+ inviteToAccount,
9
+ joinAccount,
10
+ type AccountCliStore,
11
+ } from "./account-join-cli.js";
12
+ import { openAccountBundle, sealAccountBundle } from "./core/account-bundle.js";
13
+ import {
14
+ importAccountKey,
15
+ loadOrCreateUserIdentity,
16
+ readAccountContents,
17
+ type UserIdentityOptions,
18
+ } from "./core/user-identity.js";
19
+
20
+ /** Where a script supplies the passphrase instead of typing it. */
21
+ export const ACCOUNT_PASSPHRASE_ENV = "AGENT_COMMS_ACCOUNT_PASSPHRASE";
22
+
23
+ /** A bundle holds the account key, so its file is readable by its owner alone. */
24
+ const BUNDLE_FILE_MODE = 0o600;
25
+
26
+ export const ACCOUNT_USAGE = [
27
+ "Usage: agent-comms account export <file>",
28
+ " agent-comms account import <file>",
29
+ " agent-comms account invite [--ttl-minutes <n>] [--sign-key <file>]",
30
+ " agent-comms account join [--public-key <file>] [--fingerprint <hex>] [--label <name>]",
31
+ ].join("\n");
32
+
33
+ export interface AccountCliIo {
34
+ /** Reads a secret (a passphrase, an invite) without echoing it. */
35
+ readSecret: (prompt: string) => Promise<string>;
36
+ log: (line: string) => void;
37
+ env: Readonly<Record<string, string | undefined>>;
38
+ /** Starts a store on this machine's mesh for invite and join, which need one to reach the other machine. */
39
+ openStore: () => Promise<AccountCliStore>;
40
+ }
41
+
42
+ async function passphrase(
43
+ io: Readonly<AccountCliIo>,
44
+ confirm: boolean,
45
+ ): Promise<string> {
46
+ const fromEnv = io.env[ACCOUNT_PASSPHRASE_ENV];
47
+ if (fromEnv !== undefined) return fromEnv;
48
+ const first = await io.readSecret("Account bundle passphrase: ");
49
+ if (!confirm) return first;
50
+ const second = await io.readSecret("Repeat the passphrase: ");
51
+ if (first !== second) throw new Error("The passphrases do not match");
52
+ return first;
53
+ }
54
+
55
+ function principalOf(identity: Readonly<{ deviceId: Uint8Array }>): string {
56
+ return deviceIdToHex(Uint8Array.from(identity.deviceId));
57
+ }
58
+
59
+ /** Runs one `account` subcommand. Throws with a message fit to show the person on any failure: an unknown subcommand or option, a file that already exists (an export never overwrites), a weak or wrong passphrase, a file that is not a bundle, or an invite that is malformed or refused. */
60
+ export async function runAccountCommand(
61
+ args: readonly string[],
62
+ io: Readonly<AccountCliIo>,
63
+ userIdentityOptions: Readonly<UserIdentityOptions> = {},
64
+ ): Promise<void> {
65
+ const [subcommand, ...rest] = args;
66
+ if (subcommand === "invite") {
67
+ await inviteToAccount(rest, io);
68
+ return;
69
+ }
70
+ if (subcommand === "join") {
71
+ await joinAccount(rest, io);
72
+ return;
73
+ }
74
+ const [file, ...extra] = rest;
75
+ if (file === undefined || extra.length > 0) throw new Error(ACCOUNT_USAGE);
76
+ if (subcommand === "export") {
77
+ const identity = loadOrCreateUserIdentity(userIdentityOptions);
78
+ const bundle = sealAccountBundle(
79
+ readAccountContents(userIdentityOptions),
80
+ await passphrase(io, true),
81
+ );
82
+ fs.writeFileSync(file, `${bundle}\n`, {
83
+ encoding: "utf-8",
84
+ mode: BUNDLE_FILE_MODE,
85
+ flag: "wx",
86
+ });
87
+ io.log(
88
+ `Exported account ${principalOf(identity)} to ${file}. Anyone with this file and its passphrase holds the account; import it on your other machine with \`agent-comms account import ${file}\`, then delete it.`,
89
+ );
90
+ return;
91
+ }
92
+ if (subcommand === "import") {
93
+ const contents = openAccountBundle(
94
+ fs.readFileSync(file, "utf-8"),
95
+ await passphrase(io, false),
96
+ );
97
+ const { identity, replacedFile } = importAccountKey(
98
+ userIdentityOptions,
99
+ contents,
100
+ );
101
+ io.log(`This machine now holds account ${principalOf(identity)}.`);
102
+ if (replacedFile !== undefined) {
103
+ io.log(
104
+ `The account it held before was moved to ${replacedFile}; it is still the only key that can revoke what that account issued.`,
105
+ );
106
+ }
107
+ io.log("Restart running agent-comms bridges so they use it.");
108
+ return;
109
+ }
110
+ throw new Error(ACCOUNT_USAGE);
111
+ }
112
+
113
+ /** What one keypress does to a hidden line being typed. */
114
+ export type HiddenLineStep =
115
+ | { kind: "typing"; value: string }
116
+ | { kind: "done"; value: string }
117
+ | { kind: "failed"; message: string };
118
+
119
+ const END_OF_TEXT = "\u0003";
120
+ const END_OF_TRANSMISSION = "\u0004";
121
+ const BACKSPACE = "\b";
122
+ const DELETE = "\u007f";
123
+ /** Every character ordered before this one is a C0 control character, none of which a passphrase can hold. */
124
+ const FIRST_PRINTABLE = " ";
125
+
126
+ /** Applies one character typed at a hidden prompt to the line so far. Enter, or Ctrl-D on a line with something on it, ends the line; Ctrl-C, and Ctrl-D on an empty line, cancel it. Backspace and Delete remove the last character. An escape sequence (an arrow or function key) or any other control character is refused rather than kept, since it would end up in the value invisibly and could never be typed the same way again. */
127
+ export function hiddenLineStep(
128
+ value: string,
129
+ character: string,
130
+ ): HiddenLineStep {
131
+ if (character === "\r" || character === "\n") return { kind: "done", value };
132
+ if (character === END_OF_TEXT)
133
+ return { kind: "failed", message: "Cancelled" };
134
+ if (character === END_OF_TRANSMISSION) {
135
+ return value === ""
136
+ ? { kind: "failed", message: "Cancelled: no input before end of input" }
137
+ : { kind: "done", value };
138
+ }
139
+ if (character === BACKSPACE || character === DELETE) {
140
+ // A terminal's backspace removes one visible character, which may be several code points.
141
+ const graphemes = Array.from(
142
+ new Intl.Segmenter().segment(value),
143
+ ({ segment }) => segment,
144
+ );
145
+ return { kind: "typing", value: graphemes.slice(0, -1).join("") };
146
+ }
147
+ // Escape, which starts every arrow and function key's sequence, is one of these.
148
+ if (character < FIRST_PRINTABLE) {
149
+ return {
150
+ kind: "failed",
151
+ message:
152
+ "Arrow keys, Escape and other control keys cannot be used at this prompt, since they would be kept in it invisibly; start again and type it plainly",
153
+ };
154
+ }
155
+ return { kind: "typing", value: value + character };
156
+ }
157
+
158
+ /** Reads one line from a terminal with echo off, keystroke by keystroke as hiddenLineStep describes. Throws when stdin is not a terminal, since there is nowhere to prompt; a bundle passphrase can then come from ACCOUNT_PASSPHRASE_ENV instead. */
159
+ export async function readHiddenLine(prompt: string): Promise<string> {
160
+ const { stdin, stderr } = process;
161
+ if (!stdin.isTTY) {
162
+ throw new Error(
163
+ `No terminal to ask on (${prompt.trim()}); a bundle passphrase can be set in ${ACCOUNT_PASSPHRASE_ENV} instead`,
164
+ );
165
+ }
166
+ stderr.write(prompt);
167
+ stdin.setRawMode(true);
168
+ stdin.setEncoding("utf-8");
169
+ stdin.resume();
170
+ return new Promise((resolve, reject) => {
171
+ let value = "";
172
+ // Replaced once the listener exists, so the listener can detach itself.
173
+ let finish = (): void => undefined;
174
+ const onData = (chunk: string): void => {
175
+ for (const character of chunk) {
176
+ const step = hiddenLineStep(value, character);
177
+ if (step.kind === "typing") {
178
+ value = step.value;
179
+ continue;
180
+ }
181
+ finish();
182
+ if (step.kind === "done") resolve(step.value);
183
+ else reject(new Error(step.message));
184
+ return;
185
+ }
186
+ };
187
+ finish = (): void => {
188
+ stdin.off("data", onData);
189
+ stdin.setRawMode(false);
190
+ stdin.pause();
191
+ stderr.write("\n");
192
+ };
193
+ stdin.on("data", onData);
194
+ });
195
+ }
@@ -0,0 +1,191 @@
1
+ /**
2
+ * `agent-comms account invite` and `agent-comms account join` (agent-comms#344): joining a new machine to the account over the mesh (see core/account-join.ts for the flow). Each runs a store of its own for as long as the command runs. An invite is printed to this terminal only, and the command keeps its store up until the invite is redeemed or expires, since invites live only in the issuing store's memory. A join reads the invite at a prompt that does not echo, so it never sits in shell history.
3
+ */
4
+
5
+ import * as fs from "node:fs";
6
+ import type { AccountJoin } from "./core/account-join.js";
7
+ import { createBridgeMesh } from "./core/bridge-mesh.js";
8
+ import { createMeshErrorReporter } from "./core/error-reporting.js";
9
+ import {
10
+ releaseIdentityLock,
11
+ type IdentitySlot,
12
+ } from "./core/identity-store.js";
13
+ import type { GenerateConnectionCodeOptions } from "./core/connection-code.js";
14
+ import type { RedeemConnectionCodeOptions } from "./core/connection-code.js";
15
+ import { ConnectionCodeSchema, type ConnectionCode } from "./core/types.js";
16
+ import { ACCOUNT_USAGE, type AccountCliIo } from "./account-cli.js";
17
+
18
+ /** A store started for one invite or join, and its shutdown. */
19
+ export interface AccountCliStore {
20
+ account: Pick<AccountJoin, "invite" | "join">;
21
+ close: () => Promise<void>;
22
+ }
23
+
24
+ /** The identity slot harness these commands' own store runs under, apart from every bridge's. */
25
+ const ACCOUNT_CLI_HARNESS = "account";
26
+
27
+ /** Starts a store on this machine's mesh, holding the machine's account, with one hidden agent: that is what gives it a hub session once it trusts the other machine, and a hidden agent is reachable by device id without being listed. */
28
+ export async function openMachineAccountStore(): Promise<AccountCliStore> {
29
+ const slot: IdentitySlot = {
30
+ harness: ACCOUNT_CLI_HARNESS,
31
+ cwd: process.cwd(),
32
+ };
33
+ const { store } = await createBridgeMesh(slot);
34
+ store.onError = createMeshErrorReporter();
35
+ await store.init();
36
+ const agent = await store.registerAgent({
37
+ name: ACCOUNT_CLI_HARNESS,
38
+ harness: ACCOUNT_CLI_HARNESS,
39
+ cwd: slot.cwd,
40
+ pid: process.pid,
41
+ visibility: "hidden",
42
+ tags: [],
43
+ });
44
+ return {
45
+ account: store.account,
46
+ close: async () => {
47
+ await store.setAgentOffline(agent.id);
48
+ await store.shutdown();
49
+ releaseIdentityLock(slot);
50
+ },
51
+ };
52
+ }
53
+
54
+ const MS_PER_SECOND = 1000;
55
+ const SECONDS_PER_MINUTE = 60;
56
+ const MS_PER_MINUTE = SECONDS_PER_MINUTE * MS_PER_SECOND;
57
+
58
+ /** Parses `--name value` pairs, allowing only `allowed` names, each at most once. */
59
+ function parseOptions(
60
+ args: readonly string[],
61
+ allowed: readonly string[],
62
+ ): Map<string, string> {
63
+ const options = new Map<string, string>();
64
+ for (let i = 0; i < args.length; i += 2) {
65
+ const name = args[i];
66
+ const value = args[i + 1];
67
+ if (
68
+ name === undefined ||
69
+ !name.startsWith("--") ||
70
+ !allowed.includes(name.slice(2)) ||
71
+ options.has(name.slice(2)) ||
72
+ value === undefined
73
+ ) {
74
+ throw new Error(ACCOUNT_USAGE);
75
+ }
76
+ options.set(name.slice(2), value);
77
+ }
78
+ return options;
79
+ }
80
+
81
+ function ttlMsFrom(minutes: string | undefined): number | undefined {
82
+ if (minutes === undefined) return undefined;
83
+ const parsed = Number(minutes);
84
+ if (!Number.isInteger(parsed) || parsed <= 0) {
85
+ throw new Error(
86
+ `--ttl-minutes must be a whole number of minutes above zero, not ${minutes}`,
87
+ );
88
+ }
89
+ return parsed * MS_PER_MINUTE;
90
+ }
91
+
92
+ async function signingOptions(
93
+ keyFile: string | undefined,
94
+ io: Readonly<AccountCliIo>,
95
+ ): Promise<
96
+ Pick<GenerateConnectionCodeOptions, "privateKeyArmored" | "passphrase">
97
+ > {
98
+ if (keyFile === undefined) return {};
99
+ const privateKeyArmored = fs.readFileSync(keyFile, "utf-8");
100
+ const passphrase = await io.readSecret(
101
+ "PGP key passphrase (press Enter if it has none): ",
102
+ );
103
+ return passphrase === ""
104
+ ? { privateKeyArmored }
105
+ : { privateKeyArmored, passphrase };
106
+ }
107
+
108
+ /** Issues an invite, prints it, and returns once it has been redeemed or has expired. */
109
+ export async function inviteToAccount(
110
+ args: readonly string[],
111
+ io: Readonly<AccountCliIo>,
112
+ ): Promise<void> {
113
+ const options = parseOptions(args, ["ttl-minutes", "sign-key"]);
114
+ const ttlMs = ttlMsFrom(options.get("ttl-minutes"));
115
+ const signing = await signingOptions(options.get("sign-key"), io);
116
+ const store = await io.openStore();
117
+ try {
118
+ const { code, settled } = await store.account.invite({
119
+ ...(ttlMs !== undefined && { ttlMs }),
120
+ ...signing,
121
+ });
122
+ io.log(
123
+ `Account invite, valid until ${code.expiresAt} and for one use. Whoever redeems it takes a full copy of this account, so give it only to your own new machine, out of band, and run \`agent-comms account join\` there. Keep this command running until then.`,
124
+ );
125
+ io.log(JSON.stringify(code));
126
+ const outcome = await settled;
127
+ io.log(
128
+ outcome === "redeemed"
129
+ ? "The invite was redeemed: the new machine now holds this account."
130
+ : "The invite expired unused.",
131
+ );
132
+ } finally {
133
+ await store.close();
134
+ }
135
+ }
136
+
137
+ const NOT_AN_INVITE =
138
+ "That is not an account invite: paste the whole line `agent-comms account invite` printed";
139
+
140
+ function parseInvite(raw: string): ConnectionCode {
141
+ let parsed: unknown;
142
+ try {
143
+ parsed = JSON.parse(raw);
144
+ } catch {
145
+ throw new Error(NOT_AN_INVITE);
146
+ }
147
+ if (!ConnectionCodeSchema.is(parsed)) {
148
+ throw new Error(NOT_AN_INVITE);
149
+ }
150
+ return parsed;
151
+ }
152
+
153
+ function verifyingOptions(
154
+ options: ReadonlyMap<string, string>,
155
+ ): RedeemConnectionCodeOptions {
156
+ const keyFile = options.get("public-key");
157
+ const fingerprint = options.get("fingerprint");
158
+ return {
159
+ ...(keyFile !== undefined && {
160
+ publicKeyArmored: fs.readFileSync(keyFile, "utf-8"),
161
+ }),
162
+ ...(fingerprint !== undefined && { expectedFingerprint: fingerprint }),
163
+ };
164
+ }
165
+
166
+ /** Reads an invite at a prompt and makes this machine hold the account it came from. With --label, the issuing machine keeps the label as its own name for this one. */
167
+ export async function joinAccount(
168
+ args: readonly string[],
169
+ io: Readonly<AccountCliIo>,
170
+ ): Promise<void> {
171
+ const options = parseOptions(args, ["public-key", "fingerprint", "label"]);
172
+ const label = options.get("label");
173
+ const joinOptions = {
174
+ ...verifyingOptions(options),
175
+ ...(label !== undefined && { label }),
176
+ };
177
+ const invite = parseInvite(await io.readSecret("Account invite: "));
178
+ const store = await io.openStore();
179
+ try {
180
+ const result = await store.account.join(invite, joinOptions);
181
+ io.log(`This machine now holds account ${result.principal}.`);
182
+ if (result.replacedFile !== undefined) {
183
+ io.log(
184
+ `The account it held before was moved to ${result.replacedFile}; it is still the only key that can revoke what that account issued.`,
185
+ );
186
+ }
187
+ io.log("Restart running agent-comms bridges so they use it.");
188
+ } finally {
189
+ await store.close();
190
+ }
191
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Lets a cc-peer session answer a room join or first-contact DM request. A session reached only through cc-peer (no agent-comms tool of its own) cannot call room_accept or room_reject, so the relayed request tells it exactly what to message back, and the relay runs the same tool action when that message arrives. The person at the session decides: only a message from that session's own socket ever reaches its relay.
3
+ */
4
+
5
+ import { buildAction, formatDeliveryEvent } from "../../core/bridge.js";
6
+ import type { CommsContext, CommsTool } from "../../core/tool.js";
7
+ import type { DeliveryEvent } from "../../core/types.js";
8
+
9
+ /** A device-id is a 64-character lowercase hex SHA-256 digest. */
10
+ const DEVICE_ID_HEX = "[0-9a-f]{64}";
11
+
12
+ /** `accept <room> <requester>` or `reject <room> <requester> [reason]`, the whole message and nothing else, so an ordinary message that merely mentions the word is never mistaken for a decision. */
13
+ const APPROVAL_COMMAND = new RegExp(
14
+ `^\\s*(accept|reject)\\s+(\\S+)\\s+(${DEVICE_ID_HEX})(?:\\s+(\\S[\\s\\S]*?))?\\s*$`,
15
+ "i",
16
+ );
17
+
18
+ export type ApprovalCommand =
19
+ | { kind: "accept"; room: string; requesterId: string }
20
+ | { kind: "reject"; room: string; requesterId: string; reason?: string };
21
+
22
+ /** The command a message body is, or undefined when it is not a well-formed one. */
23
+ export function parseApprovalCommand(
24
+ body: string,
25
+ ): ApprovalCommand | undefined {
26
+ const match = APPROVAL_COMMAND.exec(body);
27
+ if (match === null) return undefined;
28
+ const [, verb, room, requesterId, reason] = match;
29
+ if (verb === undefined || room === undefined || requesterId === undefined) {
30
+ return undefined;
31
+ }
32
+ if (verb.toLowerCase() === "accept") {
33
+ return { kind: "accept", room, requesterId: requesterId.toLowerCase() };
34
+ }
35
+ return {
36
+ kind: "reject",
37
+ room,
38
+ requesterId: requesterId.toLowerCase(),
39
+ ...(reason !== undefined ? { reason } : {}),
40
+ };
41
+ }
42
+
43
+ /** Renders a delivery event for a cc-peer session. A join request carries the instructions for answering it through `peerName`, the peer the session should message; every other event renders as the shared formatter does. */
44
+ export function formatCcPeerDelivery(
45
+ event: DeliveryEvent,
46
+ peerName: string,
47
+ ): string {
48
+ if (event.type !== "room_join_request") return formatDeliveryEvent(event);
49
+ return [
50
+ `${event.requesterId} is asking to join ${event.room}.`,
51
+ `To answer, send the peer "${peerName}" one of these messages:`,
52
+ `accept ${event.room} ${event.requesterId}`,
53
+ `reject ${event.room} ${event.requesterId} <optional reason>`,
54
+ ].join("\n");
55
+ }
56
+
57
+ export interface AnswerJoinRequestDeps {
58
+ tool: Pick<CommsTool, "handle">;
59
+ ctx: Readonly<CommsContext>;
60
+ }
61
+
62
+ /** Runs the room_accept or room_reject the command asks for as this session's own agent, and returns the tool's own text so the session learns what happened, including why a decision failed. */
63
+ export async function answerJoinRequest(
64
+ deps: Readonly<AnswerJoinRequestDeps>,
65
+ command: Readonly<ApprovalCommand>,
66
+ ): Promise<string> {
67
+ const action =
68
+ command.kind === "accept"
69
+ ? buildAction({
70
+ action: "room_accept",
71
+ room: command.room,
72
+ requesterId: command.requesterId,
73
+ })
74
+ : buildAction({
75
+ action: "room_reject",
76
+ room: command.room,
77
+ requesterId: command.requesterId,
78
+ ...(command.reason !== undefined ? { reason: command.reason } : {}),
79
+ });
80
+ const result = await deps.tool.handle(deps.ctx, action);
81
+ return result.content;
82
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * cc-peer bridge — relays between one local Claude Code peer (reached via cc-peer's own native cross-session protocol) and this side's agent-comms mesh, giving that local session cross-machine reach through the mesh's own transport (agent-comms#87).
3
+ *
4
+ * One bridge process relays for exactly one target local Claude Code peer, matching the "one bridge process is one agent is one device" model every other bridge in this codebase already follows -- not a many-to-one fan-out. Inbound cc-peer messages from that target are posted into this agent's own project room (the same auto-created room ensureRegistered/ensureProjectRoom already gives every bridge); mesh deliveries for this agent are relayed back to that same target via CcPeer.send().
5
+ *
6
+ * The wiring itself (wireCcPeerBridge) is dependency-injected and deliberately separate from the real CcPeer/MeshStore construction in run.ts, so it can be tested against a fake CcPeer without a real local Claude Code session.
7
+ */
8
+
9
+ import type { CommsTool } from "../../core/tool.js";
10
+ import { buildAction } from "../../core/bridge.js";
11
+ import {
12
+ answerJoinRequest,
13
+ formatCcPeerDelivery,
14
+ parseApprovalCommand,
15
+ } from "./approval-commands.js";
16
+ import type { DeliveryEvent } from "../../core/types.js";
17
+
18
+ /** How cc-peer addresses a target peer -- mirrors cc-peer's own PeerRef type without importing it, so this file has no direct dependency on the cc-peer package (only run.ts, which does the real construction, needs that). */
19
+ export type CcPeerRef =
20
+ { pid: number } | { name: string } | { address: string };
21
+
22
+ /** The one inbound-message shape this bridge reads from CcPeer's own "message" event -- narrowed to the fields it actually uses. */
23
+ export interface CcPeerInboundMessage {
24
+ from?: string;
25
+ fromName?: string;
26
+ body: string;
27
+ }
28
+
29
+ /** The narrow slice of CcPeer's own real API this bridge needs -- satisfied by the real class in run.ts, and by a fake in tests. */
30
+ export interface CcPeerLike {
31
+ on: (
32
+ event: "message",
33
+ listener: (m: Readonly<CcPeerInboundMessage>) => void,
34
+ ) => void;
35
+ send: (
36
+ target: Readonly<CcPeerRef>,
37
+ body: string,
38
+ ) => Promise<{ msgId: string }>;
39
+ }
40
+
41
+ /** The narrow slice of MeshStore this bridge needs -- onDelivery is MeshStore-only (not part of the generic CommsStore interface every bridge otherwise depends on), since only a mesh-backed store can push. */
42
+ export interface CcPeerBridgeStore {
43
+ onDelivery:
44
+ | ((agentId: string, event: DeliveryEvent) => void | Promise<void>)
45
+ | undefined;
46
+ /** Where a failure relaying an inbound message is reported, since the relay is fire-and-forget from the cc-peer event's own point of view. */
47
+ onError: ((error: Error) => void) | undefined;
48
+ }
49
+
50
+ /** The slice of a cc-peer roster entry needed to tell whether it is a given target. */
51
+ export interface CcPeerTargetEntry {
52
+ pid: number;
53
+ name?: string | undefined;
54
+ messagingSocketPath: string;
55
+ }
56
+
57
+ /** Whether a roster entry is the session a target ref addresses. An address target names the session's own `uds:<socket path>` address. */
58
+ export function targetMatchesEntry(
59
+ target: Readonly<CcPeerRef>,
60
+ entry: Readonly<CcPeerTargetEntry>,
61
+ ): boolean {
62
+ if ("pid" in target) return entry.pid === target.pid;
63
+ if ("name" in target) return entry.name === target.name;
64
+ return target.address === `uds:${entry.messagingSocketPath}`;
65
+ }
66
+
67
+ /** Builds the predicate that says whether an inbound message was sent by the target session. The sender is identified by its `uds:<socket path>` address, resolved against a fresh roster read for every message so a target addressed by name is still recognised after that session restarts under a new pid. */
68
+ export function createTargetSenderMatcher(
69
+ target: Readonly<CcPeerRef>,
70
+ listRoster: () => Promise<readonly CcPeerTargetEntry[]>,
71
+ ): (message: Readonly<CcPeerInboundMessage>) => Promise<boolean> {
72
+ return async (message) => {
73
+ if (message.from === undefined) return false;
74
+ if ("address" in target) return message.from === target.address;
75
+ const roster = await listRoster();
76
+ const sender = roster.find(
77
+ (entry) => `uds:${entry.messagingSocketPath}` === message.from,
78
+ );
79
+ return sender !== undefined && targetMatchesEntry(target, sender);
80
+ };
81
+ }
82
+
83
+ export interface CcPeerBridgeDeps {
84
+ store: CcPeerBridgeStore;
85
+ tool: Pick<CommsTool, "handle">;
86
+ peer: CcPeerLike;
87
+ agentId: string;
88
+ roomId: string;
89
+ target: Readonly<CcPeerRef>;
90
+ cwd: string;
91
+ /** The name this bridge's peer is registered under, which the target is told to message to answer a join request. */
92
+ peerName: string;
93
+ /** Whether an inbound message was sent by the target session. Only those are posted into the project room: the peer may be shared with the default front, whose fronted sessions message it as well, and those belong to the front's own relay. */
94
+ isFromTarget: (message: Readonly<CcPeerInboundMessage>) => Promise<boolean>;
95
+ }
96
+
97
+ /** Wires the two directions of the relay. Never awaited by the caller -- both directions are genuinely fire-and-forget from this function's own point of view (a send failure surfaces through cc-peer's own receipt events / agent-comms' own delivery-status events, not a thrown error here). */
98
+ export function wireCcPeerBridge(deps: Readonly<CcPeerBridgeDeps>): void {
99
+ deps.peer.on("message", (m) => {
100
+ void (async () => {
101
+ if (!(await deps.isFromTarget(m))) return;
102
+ const ctx = {
103
+ agentId: deps.agentId,
104
+ harness: "cc-peer",
105
+ cwd: deps.cwd,
106
+ pid: process.pid,
107
+ };
108
+ // A well-formed accept or reject is the target's decision on a waiting join request, not something to post into the project room.
109
+ const decision = parseApprovalCommand(m.body);
110
+ if (decision !== undefined) {
111
+ const text = await answerJoinRequest(
112
+ { tool: deps.tool, ctx },
113
+ decision,
114
+ );
115
+ await deps.peer.send(deps.target, text);
116
+ return;
117
+ }
118
+ const sender = m.fromName ?? m.from ?? "unknown";
119
+ const action = buildAction({
120
+ action: "send",
121
+ room: deps.roomId,
122
+ content: `${sender}: ${m.body}`,
123
+ });
124
+ await deps.tool.handle(ctx, action);
125
+ })().catch((error: unknown) => {
126
+ deps.store.onError?.(
127
+ error instanceof Error ? error : new Error(String(error)),
128
+ );
129
+ });
130
+ });
131
+
132
+ deps.store.onDelivery = (_targetId, event) => {
133
+ void deps.peer.send(
134
+ deps.target,
135
+ formatCcPeerDelivery(event, deps.peerName),
136
+ );
137
+ };
138
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Wires the default cc-peer front (agent-comms#157) onto a real bridge's own store, right after construction -- the piece that actually makes "a machine's coordinator bridge fronts local Claude Code sessions by default" true, rather than a capability every bridge builds but nothing ever switches on. Every real bridge entry point (pi, claude-code, mcp, codex, opencode, user, cc-peer's own one-shot command) calls this once, immediately after createBridgeMesh/createBridgeMeshSync.
3
+ *
4
+ * Deliberately the only file outside bridges/cc-peer/ that ever needs to know cc-peer exists: core/mesh-store.ts's onCoordinatorRoleChanged hook is generic (a bare boolean callback), and core/bridge-mesh.ts's factories return a plain MeshStore with no cc-peer awareness at all -- keeping this Node/filesystem/cc-peer-specific capability out of the transport-agnostic core, per this repo's own portable-runtime-boundary convention. A bridge that never becomes this machine's coordinator never starts a front at all; one that does gets it started and stopped automatically as that role comes and goes.
5
+ */
6
+
7
+ import type { MeshStore } from "../../core/mesh-store.js";
8
+ import {
9
+ createDefaultCcPeerFront,
10
+ type CreateDefaultCcPeerFrontOptions,
11
+ } from "./front-runtime.js";
12
+ import type { CcPeerFront } from "./front-controller.js";
13
+ import type { FrontedSessionRecord } from "./front-controller.js";
14
+
15
+ export interface WireDefaultCcPeerFrontOptions {
16
+ coordinatorPort?: number | undefined;
17
+ hubUrl?: string | undefined;
18
+ /** The cc-peer peer this process already owns, lent to the front instead of it creating a second one -- see CreateDefaultCcPeerFrontOptions.peer. */
19
+ peer?: CreateDefaultCcPeerFrontOptions["peer"];
20
+ /** The name the lent peer is registered under -- see CreateDefaultCcPeerFrontOptions.peerName. */
21
+ peerName?: CreateDefaultCcPeerFrontOptions["peerName"];
22
+ /** Sessions this process already relays by other means -- see CreateDefaultCcPeerFrontOptions.excludeSession. */
23
+ excludeSession?: CreateDefaultCcPeerFrontOptions["excludeSession"];
24
+ /** Builds the front itself -- defaults to the real createDefaultCcPeerFront. Overridable so this function's own start/stop wiring against store.onCoordinatorRoleChanged is testable without a real CcPeer/local Claude Code session. */
25
+ createFront?:
26
+ | ((
27
+ options: Readonly<CreateDefaultCcPeerFrontOptions>,
28
+ ) => Pick<CcPeerFront<FrontedSessionRecord>, "start" | "stop">)
29
+ | undefined;
30
+ }
31
+
32
+ /**
33
+ * Builds the store.onCoordinatorRoleChanged callback for a real bridge's own store. Returns the callback rather than assigning it directly, so the caller does the actual store.onCoordinatorRoleChanged = ... assignment itself -- this keeps the store parameter here read-only, matching every other pure-construction function in this file's neighbourhood, instead of this function reaching into the store to mutate it.
34
+ */
35
+ export function wireDefaultCcPeerFront(
36
+ store: Readonly<Pick<MeshStore, "onError">>,
37
+ options: Readonly<WireDefaultCcPeerFrontOptions> = {},
38
+ ): (isCoordinator: boolean) => Promise<void> {
39
+ const createFront = options.createFront ?? createDefaultCcPeerFront;
40
+ const front = createFront({
41
+ coordinatorPort: options.coordinatorPort,
42
+ hubUrl: options.hubUrl,
43
+ peer: options.peer,
44
+ peerName: options.peerName,
45
+ excludeSession: options.excludeSession,
46
+ onError: (error) => {
47
+ store.onError?.(error);
48
+ },
49
+ });
50
+
51
+ return async (isCoordinator: boolean) => {
52
+ if (isCoordinator) {
53
+ front.start();
54
+ } else {
55
+ await front.stop();
56
+ }
57
+ };
58
+ }