borgmcp 5.4.0 → 5.5.0

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 (72) hide show
  1. package/README.md +12 -0
  2. package/dist/assimilate-cmd.d.ts +8 -1
  3. package/dist/assimilate-cmd.d.ts.map +1 -1
  4. package/dist/assimilate-cmd.js +54 -21
  5. package/dist/assimilate-cmd.js.map +1 -1
  6. package/dist/claude.d.ts.map +1 -1
  7. package/dist/claude.js +22 -0
  8. package/dist/claude.js.map +1 -1
  9. package/dist/cli-help.d.ts +1 -0
  10. package/dist/cli-help.d.ts.map +1 -1
  11. package/dist/cli-help.js +42 -0
  12. package/dist/cli-help.js.map +1 -1
  13. package/dist/docs-sections.d.ts.map +1 -1
  14. package/dist/docs-sections.js +8 -0
  15. package/dist/docs-sections.js.map +1 -1
  16. package/dist/local-server-cursor.d.ts +1 -1
  17. package/dist/local-server-cursor.d.ts.map +1 -1
  18. package/dist/local-server-cursor.js +14 -4
  19. package/dist/local-server-cursor.js.map +1 -1
  20. package/dist/remote-client.d.ts +14 -0
  21. package/dist/remote-client.d.ts.map +1 -1
  22. package/dist/remote-client.js +30 -14
  23. package/dist/remote-client.js.map +1 -1
  24. package/dist/representative-cmd.d.ts +87 -0
  25. package/dist/representative-cmd.d.ts.map +1 -0
  26. package/dist/representative-cmd.js +286 -0
  27. package/dist/representative-cmd.js.map +1 -0
  28. package/dist/representative-core.d.ts +197 -0
  29. package/dist/representative-core.d.ts.map +1 -0
  30. package/dist/representative-core.js +493 -0
  31. package/dist/representative-core.js.map +1 -0
  32. package/dist/representative-mcp.d.ts +30 -0
  33. package/dist/representative-mcp.d.ts.map +1 -0
  34. package/dist/representative-mcp.js +182 -0
  35. package/dist/representative-mcp.js.map +1 -0
  36. package/dist/representative-owner.d.ts +10 -0
  37. package/dist/representative-owner.d.ts.map +1 -0
  38. package/dist/representative-owner.js +107 -0
  39. package/dist/representative-owner.js.map +1 -0
  40. package/dist/representative-store.d.ts +61 -0
  41. package/dist/representative-store.d.ts.map +1 -0
  42. package/dist/representative-store.js +158 -0
  43. package/dist/representative-store.js.map +1 -0
  44. package/dist/seat-store.d.ts +13 -0
  45. package/dist/seat-store.d.ts.map +1 -1
  46. package/dist/seat-store.js +55 -10
  47. package/dist/seat-store.js.map +1 -1
  48. package/dist/stream-owner.d.ts +10 -0
  49. package/dist/stream-owner.d.ts.map +1 -1
  50. package/dist/stream-owner.js +98 -19
  51. package/dist/stream-owner.js.map +1 -1
  52. package/dist/unknown-subcommand.d.ts +1 -1
  53. package/dist/unknown-subcommand.d.ts.map +1 -1
  54. package/dist/unknown-subcommand.js +1 -0
  55. package/dist/unknown-subcommand.js.map +1 -1
  56. package/docs/HUMAN_REPRESENTATIVE.md +269 -0
  57. package/docs/RELEASING.md +2 -2
  58. package/package.json +2 -2
  59. package/src/assimilate-cmd.ts +73 -22
  60. package/src/claude.ts +22 -0
  61. package/src/cli-help.ts +45 -0
  62. package/src/docs-sections.ts +8 -0
  63. package/src/local-server-cursor.ts +11 -3
  64. package/src/remote-client.ts +45 -12
  65. package/src/representative-cmd.ts +363 -0
  66. package/src/representative-core.ts +699 -0
  67. package/src/representative-mcp.ts +209 -0
  68. package/src/representative-owner.ts +105 -0
  69. package/src/representative-store.ts +208 -0
  70. package/src/seat-store.ts +61 -10
  71. package/src/stream-owner.ts +96 -19
  72. package/src/unknown-subcommand.ts +1 -0
@@ -1 +1 @@
1
- {"version":3,"file":"unknown-subcommand.js","sourceRoot":"","sources":["../src/unknown-subcommand.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,0EAA0E;AAC1E,MAAM,CAAC,MAAM,iBAAiB,GAAG;IAC/B,OAAO;IACP,QAAQ;IACR,SAAS;IACT,QAAQ;IACR,OAAO;IACP,YAAY;IACZ,YAAY;IACZ,wBAAwB;IACxB,oBAAoB;IACpB,OAAO;IACP,SAAS;IACT,QAAQ;IACR,QAAQ;IACR,YAAY;IACZ,QAAQ;CACA,CAAC;AAEX;;;;GAIG;AACH,MAAM,UAAU,iBAAiB,CAAC,KAAyB;IACzD,IAAI,KAAK,KAAK,SAAS;QAAE,OAAO,IAAI,CAAC,CAAC,cAAc;IACpD,IAAI,KAAK,CAAC,UAAU,CAAC,GAAG,CAAC;QAAE,OAAO,IAAI,CAAC,CAAC,8BAA8B;IACtE,IAAK,iBAAuC,CAAC,QAAQ,CAAC,KAAK,CAAC;QAAE,OAAO,IAAI,CAAC;IAC1E,OAAO,KAAK,CAAC,CAAC,wCAAwC;AACxD,CAAC"}
1
+ {"version":3,"file":"unknown-subcommand.js","sourceRoot":"","sources":["../src/unknown-subcommand.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,0EAA0E;AAC1E,MAAM,CAAC,MAAM,iBAAiB,GAAG;IAC/B,OAAO;IACP,QAAQ;IACR,SAAS;IACT,QAAQ;IACR,OAAO;IACP,YAAY;IACZ,YAAY;IACZ,wBAAwB;IACxB,oBAAoB;IACpB,OAAO;IACP,SAAS;IACT,QAAQ;IACR,QAAQ;IACR,YAAY;IACZ,gBAAgB;IAChB,QAAQ;CACA,CAAC;AAEX;;;;GAIG;AACH,MAAM,UAAU,iBAAiB,CAAC,KAAyB;IACzD,IAAI,KAAK,KAAK,SAAS;QAAE,OAAO,IAAI,CAAC,CAAC,cAAc;IACpD,IAAI,KAAK,CAAC,UAAU,CAAC,GAAG,CAAC;QAAE,OAAO,IAAI,CAAC,CAAC,8BAA8B;IACtE,IAAK,iBAAuC,CAAC,QAAQ,CAAC,KAAK,CAAC;QAAE,OAAO,IAAI,CAAC;IAC1E,OAAO,KAAK,CAAC,CAAC,wCAAwC;AACxD,CAAC"}
@@ -0,0 +1,269 @@
1
+ # Human Representative
2
+
3
+ `borg representative` lets a standard MCP host — for example Hermes — speak
4
+ **for the human** to one existing Coordinator drone and read its replies. It is
5
+ a client-side feature: it uses the ordinary cube log, addressing,
6
+ acknowledgement and saved-connection mechanisms. The Borg server has no special
7
+ "representative" concept and enforces nothing extra for it.
8
+
9
+ ## Vocabulary
10
+
11
+ - **Cube**: one repository's shared coordination space on your Borg server.
12
+ - **Drone**: one connected agent session in a cube. Its **role** defines how it
13
+ works.
14
+ - **Human seat**: the one role in a cube that speaks with the human's
15
+ authority.
16
+ - **Coordinator**: the drone holding the human seat. It owns the coordination
17
+ playbook and dispatches the other drones.
18
+ - **Human representative**: a *separate* automated drone under its own
19
+ non-human-seat role. It relays the human's requests, questions and decisions
20
+ to that one Coordinator and reads the Coordinator's replies. It is not the
21
+ human, never takes or replaces the human seat, carries none of the
22
+ Coordinator's playbook, and cannot address other drones or broadcast.
23
+ - **Binding**: the saved selection of one server, one cube, one representative
24
+ drone and one Coordinator drone for one worktree.
25
+
26
+ ## Lifecycle
27
+
28
+ ### 1. Create the representative role once
29
+
30
+ The representative needs an existing role that is **not** the human seat and
31
+ not a coordinating (queen-class) role. The default name is
32
+ `hermes-representative`. Create it with the cube's normal role management (for
33
+ example ask the Coordinator to run `borg_create-role`). Keep its text short,
34
+ for example: *"Relays the human's requests, questions and decisions to the
35
+ Coordinator and reports the Coordinator's replies back. Does not plan, dispatch
36
+ or review work."* Do not copy the Coordinator role into it.
37
+
38
+ ### 2. Prepare the connection
39
+
40
+ From the repository whose cube you want, name the exact Coordinator drone
41
+ (`borg drones` lists labels):
42
+
43
+ ```bash
44
+ borg representative prepare --host <host:port> --coordinator <coordinator-drone-label> --worktree hermes
45
+ ```
46
+
47
+ Replace `<host:port>` with your existing Borg server's address. The bare
48
+ `host:port` form is accepted (for example `127.0.0.1:7091`) and defaults to HTTPS.
49
+ Always pass `--host` for scripted or non-interactive runs. In an interactive
50
+ terminal, omitting it makes `prepare` attempt server detection and ask you to
51
+ confirm the detected server or enter its address.
52
+
53
+ This creates the representative's own drone in a new linked worktree through
54
+ the same path as `borg assimilate --worktree`, but **launches no agent CLI** and
55
+ does not touch any other drone. It then verifies against the live cube that:
56
+
57
+ - the new drone's role is the requested one, and is neither the human seat nor
58
+ a coordinating role;
59
+ - exactly one active drone has the given label, it is not the representative
60
+ itself, and it holds the human seat.
61
+
62
+ A missing, evicted, duplicated or non-human-seat Coordinator fails with a named
63
+ error. Another drone is never chosen instead. On success the binding is saved
64
+ in Borg's private configuration directory (`representative.json`, mode 0600).
65
+ That file holds identifiers and a request ledger only — no credential and no
66
+ message text. The drone's credential stays in Borg's existing private
67
+ connection store.
68
+
69
+ To resume later, run `borg representative prepare --coordinator <coordinator-drone-label> --role <your-representative-role>` from inside the representative worktree (without `--worktree`), substituting
70
+ your saved labels. Recovery errors for a bound connection print that complete
71
+ command with its actual labels and worktree. Changing the cube or Coordinator
72
+ is refused unless you
73
+ pass `--rebind`; a rebind also discards the old request ledger. A running
74
+ `borg representative mcp` process never picks up a rebind: its calls fail
75
+ closed until the MCP host restarts it.
76
+
77
+ `prepare` reuses Borg's connection and worktree preparation, including its
78
+ private-state initialization and saved-connection checks. It does not require
79
+ Claude Code, Codex or OpenCode, write their configuration or preferences,
80
+ provision their launch access, report an agent identity, or install a session
81
+ hook. Nothing is started. An explicit `--host` that conflicts with the saved
82
+ connection is refused before preparation.
83
+
84
+ A confirmed evicted drone uses the ordinary connection recovery path. Revoked,
85
+ superseded, unreachable and changed-trust connections remain distinct refusals;
86
+ they are not treated as eviction. After recovery changes the drone identity,
87
+ confirm the new selection with the printed `--rebind` command.
88
+
89
+ First-time role creation and actual linked-worktree provisioning have not yet
90
+ been exercised live for this interface. The current evidence uses controlled
91
+ backends; this is not a claim of live onboarding verification.
92
+
93
+ ### 3. Configure the MCP host
94
+
95
+ The Borg server speaks pinned-TLS HTTPS, not MCP, so the host starts a local
96
+ stdio MCP process. A generic configuration (shown in Hermes-style YAML; adapt
97
+ the keys to your host):
98
+
99
+ ```yaml
100
+ mcp_servers:
101
+ borg-representative:
102
+ command: borg
103
+ args: ["representative", "mcp", "--worktree", "/absolute/path/to/representative/worktree"]
104
+ ```
105
+
106
+ No token, key or URL belongs in this configuration. If the worktree is not
107
+ prepared, or its saved connection no longer matches the binding, the process
108
+ exits with an error on stderr and writes nothing to stdout.
109
+
110
+ Check a connection at any time with
111
+ `borg representative status --worktree <path>`.
112
+
113
+ ### 4. Tools
114
+
115
+ | Tool | Purpose |
116
+ | --- | --- |
117
+ | `borg_representative-status` | Bound cube, representative, Coordinator; live re-check; unresolved sends; limits. |
118
+ | `borg_representative-send` | Relay one `request`, `question` or `decision` to the bound Coordinator. |
119
+ | `borg_representative-read` | Unread replies from the bound Coordinator addressed to the representative. Drains everything it fetches. |
120
+ | `borg_representative-ack` | Signal to the Coordinator that one direct reply was received. Nothing more. |
121
+
122
+ There is no tool for logging to other drones, broadcasting, role or drone
123
+ management, grants, eviction, release, regeneration or server lifecycle, and no
124
+ generic dispatcher. `send` rejects any recipient field.
125
+
126
+ New network operations re-verify the live cube first: the connection must still be the
127
+ bound drone in the bound cube, still under a permitted role, and the bound
128
+ Coordinator must still be an active human-seat drone. Otherwise the call fails
129
+ and nothing is sent. A cached sent retry returns its historical receipt without
130
+ a live re-check; use `borg_representative-status` to check the current connection.
131
+
132
+ ## Attribution and authority
133
+
134
+ Each relayed message starts with a fixed header:
135
+
136
+ ```text
137
+ [HUMAN-REPRESENTATIVE · automated relay via <representative-label> · not typed by the human]
138
+ request_id: <uuid>
139
+ kind: request | question | decision
140
+ authorization: user-authorized — … | model-advice — …
141
+ reply: direct to <representative-label>, quoting the request_id.
142
+ ---
143
+ <message>
144
+ ```
145
+
146
+ - `user_authorized` means the representative asserts the human explicitly
147
+ authorized that exact text. `model_advice` marks the model's own suggestion.
148
+ A `decision` is refused unless it is `user_authorized`.
149
+ - This label is the representative's own statement. **The Borg server does not
150
+ verify or enforce it.** On the server these are ordinary posts from the
151
+ representative drone. A relayed decision authorizes only its own text; it is
152
+ not proof of broader human approval, and the Coordinator should treat it that
153
+ way.
154
+
155
+ ## Request identity, retries and ambiguous sends
156
+
157
+ `send` returns a `request_id`, which is also the protocol `post_id`
158
+ idempotency key of the log append.
159
+
160
+ - The lookup, the conflict decision, the id allocation and the `pending`
161
+ reservation happen in one locked ledger transaction, before any network use.
162
+ Two overlapping sends of the same content without a `request_id` therefore
163
+ cannot both go out: the second is refused (`AMBIGUOUS_SEND_UNRESOLVED`) and
164
+ names the first one's `request_id`. This is not a content filter: once a
165
+ request is settled, sending identical content again is a new, legitimate
166
+ request.
167
+ - Within the same server database, cube and representative drone, re-sending
168
+ the same `request_id` with identical content never creates a second message:
169
+ a request already recorded as sent is answered from the local
170
+ ledger, and otherwise the server deduplicates on `post_id`. The same
171
+ `request_id` with different content is refused (`REQUEST_ID_CONFLICT`). The local
172
+ ledger retains only the newest 200 settled requests; unresolved requests are
173
+ retained. Older settled retries depend on server deduplication. Changing the
174
+ database, cube or drone is outside that guarantee.
175
+ - Invalid input, and a payload the protocol would refuse, fail before anything
176
+ is reserved. The live cube is verified before posting; if that check fails,
177
+ nothing was sent and the reservation is released.
178
+ - The representative makes exactly **one** transport attempt per call (the
179
+ client's usual automatic retry after a connection reset is switched off for
180
+ it). Only because of that, a typed refusal the server returns to that attempt
181
+ is reported as `SEND_REJECTED` — *this attempt was not stored*: a rejected,
182
+ revoked or superseded credential, an evicted drone or deleted cube, or an HTTP
183
+ 4xx answer. The error carries the underlying cause code and a recovery step
184
+ (for example: restore the connection with `prepare`; wait out a rate limit
185
+ and retry the same `request_id`; a `POST_ID_CONFLICT` goes to the operator).
186
+ That is a statement about the one attempt, not about the server's internals:
187
+ it assumes a server that answers 4xx instead of storing.
188
+ - Everything else is reported as `outcome: "ambiguous"`: no answer or a
189
+ timeout, an HTTP 5xx, a response that cannot be read or violates the protocol
190
+ (which can happen *after* the server stored the message), a TLS trust failure,
191
+ or any unrecognised error. The result names a bounded, sanitized `cause` and
192
+ a matching recovery hint. Nothing is re-sent across calls. The request stays
193
+ under `unresolved_requests` — across restarts — until a retry with the same
194
+ `request_id` settles it, and the same content under a new id is refused
195
+ meanwhile. There is deliberately no "forget it and resend under a new id"
196
+ operation.
197
+ - If an earlier attempt under a `request_id` was ambiguous, a later definite
198
+ refusal of a retry does not clear it: the request stays unresolved, because
199
+ the earlier attempt may have been stored.
200
+
201
+ Exactly-once delivery is therefore not claimed; at-most-once per `request_id`
202
+ relies on the server honouring `post_id` deduplication as the shared protocol
203
+ specifies, and on the single-process rule below.
204
+
205
+ ## Reading, cursors and wake limits
206
+
207
+ - `read` drains only the representative drone's **own** unread cursor. Other
208
+ drones' cursors are separate client-owned state and are never touched.
209
+ - A read **consumes everything it fetched**, not only what it returns: the
210
+ Coordinator's replies, and equally the entries it ignores (other drones'
211
+ entries are counted in `ignored_entries` and never returned; the
212
+ Coordinator's broadcasts are returned only with `include_broadcast`). None
213
+ of them appear unread again.
214
+ - If the MCP host stops between reading a reply and relaying it to the human,
215
+ that reply is gone from the unread view. It still exists in the cube log, but
216
+ this version offers no tool to list past replies again. Persist the read result
217
+ in the host before relaying it.
218
+ - Replies preserve document citations (id, title and state). Document bodies are
219
+ not included and cannot be fetched through this connection. Ask the Coordinator
220
+ to provide the content through a supported channel.
221
+ - `limit` is a page-size hint, not a hard cap: when the unread backlog is
222
+ large the client's digest mode fetches, and drains, more than `limit`.
223
+ - `ack` is only a signal to the Coordinator that a direct reply was received.
224
+ It does not make delivery reliable, and it neither advances nor restores the
225
+ unread cursor.
226
+ - Exclusive process ownership is enforced for each representative drone. Processes
227
+ may start idle; the first `send`, `read` or `ack` takes the lease. Other processes
228
+ receive `REPRESENTATIVE_OWNERSHIP_REQUIRED` before any ledger reservation or
229
+ write, cursor access, or network call. The refusal names the owner's PID and
230
+ start time. Use that host, or wait for it to exit before using another.
231
+ - `status` is allowed in every process, is read-only, and takes no lease. Its
232
+ `ownership` field reports the state, PID, start time, and heartbeat age in
233
+ milliseconds (`ageMs`). A clean exit releases ownership; a dead PID or a
234
+ heartbeat older than 70 seconds permits takeover without manual cleanup.
235
+ A process that loses its lease refuses further activity until restarted.
236
+ An already in-flight network operation cannot be cancelled by a local lease;
237
+ same-request retries across takeover still use the existing ledger and server
238
+ deduplication. Retry an ambiguous send with its original `request_id`.
239
+ - `in_reply_to` is a textual match of a known `request_id` quoted in the reply.
240
+ It is a convenience, not a protocol guarantee.
241
+ - **There is no background wake.** A generic MCP host receives nothing
242
+ unsolicited: replies are seen only when the host calls
243
+ `borg_representative-read`. This version provides explicit send/read round
244
+ trips only and makes no claim of automatic ongoing coordination. The
245
+ Coordinator is woken by the direct message through its own normal wake path.
246
+
247
+ ### Host conversation routing
248
+
249
+ The lease selects one consuming process, not a conversation within that host.
250
+ The host must record which conversation owns each `request_id`, persist every
251
+ read result before relaying it, and route replies using `in_reply_to`. Hold
252
+ replies with an unknown or missing request ID for the human instead of dropping
253
+ them. Borg cannot enforce these duties inside the host; it provides neither a
254
+ durable inbox nor a separate unread cursor for each conversation.
255
+
256
+ ## Recovery
257
+
258
+ Run `borg` with the Node installation that owns the global `borgmcp` install;
259
+ a different Node prefix can fail the local server-installation check even when
260
+ the server is installed under the original prefix.
261
+
262
+ | Error | Meaning and action |
263
+ | --- | --- |
264
+ | `NOT_PREPARED` | No binding for that worktree. Follow the initial preparation command above with an explicitly chosen Coordinator. |
265
+ | `SEAT_UNAVAILABLE` | The representative drone's saved connection is gone or rejected. Run the complete recovery command printed in the error; it includes the worktree, Coordinator and role. |
266
+ | `BINDING_MISMATCH` | The worktree's connection is not the bound server/cube/drone, or the binding changed under a running process. Run the printed command to confirm the rebind, then restart the MCP process. |
267
+ | `COORDINATOR_UNAVAILABLE` | The bound Coordinator was evicted, released or reassigned. Restore the bound Coordinator and use the printed recovery command, or deliberately substitute a new Coordinator label in that command. |
268
+ | `REPRESENTATIVE_OWNERSHIP_REQUIRED` with a directory-permission refusal | Check that the named path is a real directory you own and not a symlink, then set it to 0700 and retry. Restart a process that had already lost ownership. Do not change permissions through a symlink. |
269
+ | `REPRESENTATIVE_ROLE_NOT_PERMITTED` | The representative drone holds a human-seat or coordinating role. Give it its own worker role. |
package/docs/RELEASING.md CHANGED
@@ -21,9 +21,9 @@ Before creating the release tag, independently verify all of these conditions:
21
21
  - the extraction review confirms no private backend secrets, deployment
22
22
  configuration, customer data, local state, or duplicated shared contracts
23
23
  entered the public package;
24
- - the exact audited registry dependency `borgmcp-shared@2.1.0` remains locked to
24
+ - the exact audited registry dependency `borgmcp-shared@2.2.0` remains locked to
25
25
  its canonical tarball and integrity
26
- `sha512-wgx0iOK41bdngq6vqnzwaju+34uUOM3VM/ewrn4LfJlqP17CMjLq6xP7TOQ6E+itIfCh88u7zgGteu/j7HC0KQ==`;
26
+ `sha512-sKCKCMBvJWHAVYtFAeBLfpuNZqSJgLqEy7/k1isefgJFlHTMJJSy53bOVj6W+pLWrRSKbhhFkxJheTmFey/cIg==`;
27
27
  - the current published server and the client candidate use the same exact
28
28
  `borgmcp-shared` version; publish a compatible server before tagging the
29
29
  client when that pin changes;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "borgmcp",
3
- "version": "5.4.0",
3
+ "version": "5.5.0",
4
4
  "description": "Coordinate AI coding agents in shared cubes. Works with Claude Code, Codex, and OpenCode.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -72,7 +72,7 @@
72
72
  },
73
73
  "dependencies": {
74
74
  "@modelcontextprotocol/sdk": "^1.0.4",
75
- "borgmcp-shared": "2.1.0",
75
+ "borgmcp-shared": "2.2.0",
76
76
  "chalk": "^5.3.0",
77
77
  "prompts": "^2.4.2",
78
78
  "which": "^4.0.0"
@@ -441,6 +441,7 @@ async function selectAssimilationAuthority(
441
441
  flags: AssimilateFlags,
442
442
  deps: AuthorityResolutionDeps,
443
443
  mode: 'assimilate' | 'cube-init',
444
+ authoritySelectionCommand?: string,
444
445
  ): Promise<AssimilationAuthority | null> {
445
446
  if (flags.server !== undefined) {
446
447
  try {
@@ -460,7 +461,7 @@ async function selectAssimilationAuthority(
460
461
  }
461
462
  if (!deps.isTTY() || flags.yes) {
462
463
  if (deps.defaultAuthority) return deps.defaultAuthority;
463
- const command = mode === 'cube-init'
464
+ const command = authoritySelectionCommand ? `\`${authoritySelectionCommand}\`` : mode === 'cube-init'
464
465
  ? '`borg server cube init --host <host>`'
465
466
  : '`borg assimilate --host <host> --here`';
466
467
  deps.stderr(`No local server selected. Use ${command} to select a local server.\n`);
@@ -1132,11 +1133,11 @@ export interface CubeRoleResolutionOutcome {
1132
1133
  cli: BorgCli;
1133
1134
  }
1134
1135
 
1135
- export async function resolveAssimilationCubeRole(
1136
+ function resolveConnectionRole(
1136
1137
  input: CubeRoleResolutionInput,
1137
1138
  deps: CubeRoleResolutionDeps,
1138
- ): Promise<AssimilationPhaseOutcome<CubeRoleResolutionOutcome>> {
1139
- const { requestedRole, flags, cubeDetail, isFirstDrone, savedLocalRole, apiUrl } = input;
1139
+ ): AssimilationPhaseOutcome<{ resolvedRole: Role }> {
1140
+ const { requestedRole, cubeDetail, isFirstDrone, savedLocalRole, apiUrl } = input;
1140
1141
  let resolvedRole: Role | undefined;
1141
1142
  if (savedLocalRole) {
1142
1143
  resolvedRole = savedLocalRole;
@@ -1166,6 +1167,17 @@ export async function resolveAssimilationCubeRole(
1166
1167
  }
1167
1168
  }
1168
1169
 
1170
+ return continueAssimilation({ resolvedRole });
1171
+ }
1172
+
1173
+ export async function resolveAssimilationCubeRole(
1174
+ input: CubeRoleResolutionInput,
1175
+ deps: CubeRoleResolutionDeps,
1176
+ ): Promise<AssimilationPhaseOutcome<CubeRoleResolutionOutcome>> {
1177
+ const role = resolveConnectionRole(input, deps);
1178
+ if (role.kind === 'stop') return role;
1179
+ const { resolvedRole } = role.value;
1180
+ const { flags, apiUrl } = input;
1169
1181
  const effectiveModel: string | null = flags.model ?? null;
1170
1182
  const cli = await deps.resolveCli(flags.cli);
1171
1183
  try {
@@ -1192,7 +1204,7 @@ export interface SeatPreparationInput {
1192
1204
  serverTrustIdentity: string;
1193
1205
  cubeDetail: CubeDetail;
1194
1206
  resolvedRole: Role;
1195
- cli: BorgCli;
1207
+ cli?: BorgCli;
1196
1208
  effectiveModel: string | null;
1197
1209
  projectRoot: string;
1198
1210
  existing: CanonicalActiveCube | null;
@@ -1267,8 +1279,7 @@ export async function prepareAssimilationSeat(
1267
1279
  cube_id: cubeDetail.id,
1268
1280
  role_id: resolvedRole.id,
1269
1281
  hostname: deps.getHostname(),
1270
- agent_kind: cli,
1271
- model: effectiveModel,
1282
+ ...(cli !== undefined ? { agent_kind: cli, model: effectiveModel } : {}),
1272
1283
  working_repo: resolveWorkingRepo(projectRoot),
1273
1284
  ...(reattachPriorId ? { prior_drone_id: reattachPriorId } : {}),
1274
1285
  ...(remintInvalidPrior ? { remint_invalid_prior: true } : {}),
@@ -1471,6 +1482,7 @@ export interface AuthorityResolutionInput {
1471
1482
  args: AssimilateArgs;
1472
1483
  mode: 'assimilate' | 'cube-init';
1473
1484
  repositoryContext: GitRepositoryContext;
1485
+ authoritySelectionCommand?: string;
1474
1486
  }
1475
1487
 
1476
1488
  export interface AuthorityResolutionOutcome {
@@ -1493,6 +1505,13 @@ export async function resolveAssimilationAuthority(
1493
1505
  deps: AuthorityResolutionDeps,
1494
1506
  ): Promise<AssimilationPhaseOutcome<AuthorityResolutionOutcome>> {
1495
1507
  const { args, mode, repositoryContext } = input;
1508
+ // Representative retries must refuse before private-state initialization or
1509
+ // installation checks can mutate anything when no authority was selected.
1510
+ if (input.authoritySelectionCommand && args.flags.server === undefined &&
1511
+ deps.defaultAuthority === undefined && !args.flags.enroll && (!deps.isTTY() || args.flags.yes)) {
1512
+ await selectAssimilationAuthority(args.flags, deps, mode, input.authoritySelectionCommand);
1513
+ return { kind: 'stop', code: 1 };
1514
+ }
1496
1515
  const hostlessEnrollment = args.flags.enroll === true &&
1497
1516
  args.flags.server === undefined && deps.defaultAuthority === undefined;
1498
1517
  const artifactOnlyEnrollment = hostlessEnrollment && deps.isTTY();
@@ -1624,7 +1643,7 @@ export async function resolveAssimilationAuthority(
1624
1643
  localSeatReadError = error;
1625
1644
  }
1626
1645
 
1627
- const selectedAuthority = await selectAssimilationAuthority(args.flags, deps, mode);
1646
+ const selectedAuthority = await selectAssimilationAuthority(args.flags, deps, mode, input.authoritySelectionCommand);
1628
1647
  if (!selectedAuthority) return { kind: 'stop', code: 1 };
1629
1648
  let authority = selectedAuthority;
1630
1649
  if (localSeatReadError !== undefined) {
@@ -1750,12 +1769,31 @@ export async function runAssimilate(
1750
1769
  args: AssimilateArgs,
1751
1770
  deps: AssimilateDeps,
1752
1771
  options: RunAssimilateOptions = {},
1772
+ ): Promise<number> {
1773
+ return runAssimilationFlow(args, deps, options);
1774
+ }
1775
+
1776
+ /** Prepare a host-neutral connection using the same durable assimilation lifecycle. */
1777
+ export async function prepareConnection(
1778
+ args: AssimilateArgs,
1779
+ deps: AssimilateDeps,
1780
+ options: { validateRole: (role: Role) => void; onPrepared: (prepared: PreparedAssimilation) => void; authoritySelectionCommand?: string },
1781
+ ): Promise<number> {
1782
+ return runAssimilationFlow(args, deps, { launch: false, ...options }, options.validateRole, options.authoritySelectionCommand);
1783
+ }
1784
+
1785
+ async function runAssimilationFlow(
1786
+ args: AssimilateArgs,
1787
+ deps: AssimilateDeps,
1788
+ options: RunAssimilateOptions,
1789
+ validateConnectionRole?: (role: Role) => void,
1790
+ authoritySelectionCommand?: string,
1753
1791
  ): Promise<number> {
1754
1792
  const repository = await resolveAssimilationRepository(args, deps);
1755
1793
  if (repository.kind === 'stop') return repository.code;
1756
1794
  const { mode, repositoryContext } = repository.value;
1757
1795
 
1758
- const authorityResolution = await resolveAssimilationAuthority({ args, mode, repositoryContext }, deps);
1796
+ const authorityResolution = await resolveAssimilationAuthority({ args, mode, repositoryContext, authoritySelectionCommand }, deps);
1759
1797
  if (authorityResolution.kind === 'stop') return authorityResolution.code;
1760
1798
  const {
1761
1799
  authority,
@@ -2172,16 +2210,18 @@ export async function runAssimilate(
2172
2210
  }
2173
2211
  }
2174
2212
 
2175
- const cubeRole = await resolveAssimilationCubeRole({
2176
- requestedRole: args.role,
2177
- flags: args.flags,
2178
- cubeDetail,
2179
- isFirstDrone,
2180
- savedLocalRole,
2181
- apiUrl: authority.apiUrl,
2182
- }, deps);
2213
+ const roleInput = {
2214
+ requestedRole: args.role, flags: args.flags, cubeDetail, isFirstDrone,
2215
+ savedLocalRole, apiUrl: authority.apiUrl,
2216
+ };
2217
+ const cubeRole = validateConnectionRole
2218
+ ? resolveConnectionRole(roleInput, deps)
2219
+ : await resolveAssimilationCubeRole(roleInput, deps);
2183
2220
  if (cubeRole.kind === 'stop') return cubeRole.code;
2184
- const { resolvedRole, effectiveModel, cli } = cubeRole.value;
2221
+ const { resolvedRole } = cubeRole.value;
2222
+ validateConnectionRole?.(resolvedRole);
2223
+ const cli = 'cli' in cubeRole.value ? cubeRole.value.cli as BorgCli : undefined;
2224
+ const effectiveModel = 'effectiveModel' in cubeRole.value ? cubeRole.value.effectiveModel as string | null : null;
2185
2225
 
2186
2226
  const seat = await prepareAssimilationSeat({
2187
2227
  apiUrl: auth.apiUrl,
@@ -2202,6 +2242,7 @@ export async function runAssimilate(
2202
2242
  }, deps);
2203
2243
  if (seat.kind === 'stop') return seat.code;
2204
2244
  const { result, assignedRole, sessionExpected } = seat.value;
2245
+ validateConnectionRole?.(assignedRole);
2205
2246
 
2206
2247
  const worktree = await prepareAssimilationWorktree({
2207
2248
  flags: args.flags,
@@ -2266,7 +2307,7 @@ export async function runAssimilate(
2266
2307
  // of that request. The resolver therefore saved the preference against the
2267
2308
  // invoking checkout. Once a sibling exists, save the same choice under its
2268
2309
  // own project key so a later --here launch in that worktree can read it.
2269
- if (spawnedWorktreePath) {
2310
+ if (spawnedWorktreePath && cli !== undefined) {
2270
2311
  try {
2271
2312
  await deps.setCliPreferenceForWorktree(cli, spawnedWorktreePath);
2272
2313
  } catch (err) {
@@ -2281,8 +2322,10 @@ export async function runAssimilate(
2281
2322
  }
2282
2323
 
2283
2324
  try {
2284
- deps.mkdirp(scratchRoot);
2285
- deps.provisionLaunchAccess?.(cli, seatWorktree, launchAccessPaths);
2325
+ if (cli !== undefined) {
2326
+ deps.mkdirp(scratchRoot);
2327
+ deps.provisionLaunchAccess?.(cli, seatWorktree, launchAccessPaths);
2328
+ }
2286
2329
  } catch (err) {
2287
2330
  const message = err instanceof Error ? err.message : String(err);
2288
2331
  deps.stderr(
@@ -2320,6 +2363,14 @@ export async function runAssimilate(
2320
2363
  }
2321
2364
  }
2322
2365
 
2366
+ if (validateConnectionRole) {
2367
+ options.onPrepared?.({
2368
+ cubeId: result.cube_id, cubeName: cubeDetail.name, droneId: result.drone_id,
2369
+ droneLabel: result.drone_label, roleName: assignedRole.name, worktree: seatWorktree,
2370
+ });
2371
+ return 0;
2372
+ }
2373
+
2323
2374
  // gh#793: best-effort GC of orphaned inbox files (evicted/dead drones) in the
2324
2375
  // cube just joined — lazy-on-assimilate, no cron/new command. NEVER blocks or
2325
2376
  // fails the assimilate (whole call swallowed). Local-only signal (CubeDetail
@@ -2371,7 +2422,7 @@ export async function runAssimilate(
2371
2422
  cubeDetail,
2372
2423
  assignedRole,
2373
2424
  apiUrl: auth.apiUrl,
2374
- cli,
2425
+ cli: cli!,
2375
2426
  effectiveModel,
2376
2427
  agentCwd,
2377
2428
  seatWorktree,
package/src/claude.ts CHANGED
@@ -390,6 +390,28 @@ async function main() {
390
390
  buildDefaultSeatCommandDeps(),
391
391
  ));
392
392
  }
393
+ if (process.argv[2] === 'representative') {
394
+ // Loaded on demand: the representative facade is unrelated to agent launch.
395
+ const representative = await import('./representative-cmd.js');
396
+ const parsed = representative.parseRepresentativeArgs(process.argv.slice(3));
397
+ if (!parsed.ok) {
398
+ process.stderr.write(chalk.red(`${consolePrefix()}◼ borg representative: ${parsed.error}\n`));
399
+ process.stderr.write(`Run \`borg representative --help\` for usage.\n`);
400
+ process.exit(1);
401
+ }
402
+ const deps = await representative.buildDefaultRepresentativeDeps();
403
+ if (parsed.command.action === 'prepare') {
404
+ process.exit(await representative.runRepresentativePrepare(parsed.command, deps));
405
+ }
406
+ if (parsed.command.action === 'status') {
407
+ process.exit(await representative.runRepresentativeStatus(parsed.command, deps));
408
+ }
409
+ const { pinMcpSeatIdentity } = await import('./cubes.js');
410
+ process.exit(await representative.runRepresentativeMcp(parsed.command, deps, {
411
+ version: getPackageVersion(),
412
+ pinSeat: pinMcpSeatIdentity,
413
+ }));
414
+ }
393
415
  if (process.argv[2] === 'launch-all') {
394
416
  const parsed = parseLaunchAllArgs(process.argv.slice(3));
395
417
  if (!parsed.ok) {
package/src/cli-help.ts CHANGED
@@ -113,6 +113,49 @@ export function launchSeatHelpText(version: string): string {
113
113
  );
114
114
  }
115
115
 
116
+ export function representativeHelpText(version: string): string {
117
+ return (
118
+ `borg representative (borgmcp ${version}) — connect a human representative (e.g. Hermes) to one Coordinator\n\n` +
119
+ `Vocabulary:\n` +
120
+ ` cube One repository's shared coordination space on your Borg server.\n` +
121
+ ` drone One connected agent session in a cube; its role defines how it works.\n` +
122
+ ` human seat The one role in a cube that speaks with the human's authority.\n` +
123
+ ` Coordinator The drone holding the human seat. It plans the work and dispatches the other drones.\n` +
124
+ ` human representative A SEPARATE automated drone, under its own non-human-seat role, that relays the\n` +
125
+ ` human's requests, questions and decisions to that one Coordinator and reads its\n` +
126
+ ` replies. It is not the human, never takes the human seat, and never addresses\n` +
127
+ ` other drones or broadcasts.\n\n` +
128
+ `Usage:\n` +
129
+ ` borg representative prepare --coordinator <drone-label> [--role <name>] [--worktree <name>] [--host <host>] [--rebind]\n` +
130
+ ` borg representative status [--worktree <path>]\n` +
131
+ ` borg representative mcp [--worktree <path>]\n` +
132
+ ` borg representative --help\n\n` +
133
+ `Commands:\n` +
134
+ ` prepare Create or resume the representative's own drone in this repository's cube and bind it to\n` +
135
+ ` exactly the named Coordinator drone. Launches no agent CLI and changes no other drone.\n` +
136
+ ` Fails if that Coordinator is missing, evicted, duplicated, or not in the human seat;\n` +
137
+ ` another drone is never chosen instead.\n` +
138
+ ` status Show the saved binding, re-check it against the live cube, and list unresolved sends.\n` +
139
+ ` mcp Serve the restricted stdio MCP tools (status, send, read, ack) for a generic MCP host.\n\n` +
140
+ `Options:\n` +
141
+ ` --coordinator <drone-label> Exact label of the Coordinator drone (see \`borg drones\`). Required for prepare.\n` +
142
+ ` --role <name> Existing non-human-seat role for the representative (default: hermes-representative)\n` +
143
+ ` --worktree <name> prepare: create the drone in a new linked worktree of that name\n` +
144
+ ` --worktree <path> status/mcp: absolute path of the prepared representative worktree\n` +
145
+ ` --host <host> prepare: explicit Borg server, as in \`borg assimilate --host\`\n` +
146
+ ` --rebind prepare: explicitly replace the saved cube/Coordinator selection\n` +
147
+ ` --help, -h Show this help\n\n` +
148
+ `Limits: explicit send/read round trips only — there is no background wake or push to the MCP host.\n` +
149
+ `Reading drains everything it fetches, so relay replies at once; ack is only a signal to the Coordinator.\n` +
150
+ `Run exactly one MCP host process per representative worktree (not enforced).\n` +
151
+ `A retried send reuses its request id so the server stores it once; an unknown outcome is reported as\n` +
152
+ `ambiguous, with its cause, and never re-sent automatically. "User-authorized" is the representative's own label:\n` +
153
+ `the Borg server does not verify it, and one relayed decision is not broader human approval.\n` +
154
+ `No command takes a credential; the drone's saved connection stays in Borg's private store.\n` +
155
+ `Details: docs/HUMAN_REPRESENTATIVE.md\n`
156
+ );
157
+ }
158
+
116
159
  export function doctorHelpText(version: string): string {
117
160
  return (
118
161
  `borg doctor (borgmcp ${version}) — inspect Borg agent integrations without changing them\n\n` +
@@ -139,6 +182,7 @@ export function clientSubcommandHelpText(
139
182
  case 'drones': return seatsHelpText(version);
140
183
  case 'launch': return launchSeatHelpText(version);
141
184
  case 'launch-all': return launchAllHelpText(version);
185
+ case 'representative': return representativeHelpText(version);
142
186
  case 'doctor': return doctorHelpText(version);
143
187
  default: return null;
144
188
  }
@@ -186,6 +230,7 @@ export function topLevelHelpText(version: string): string {
186
230
  ` borg drones List this machine's registered drones and worktrees\n` +
187
231
  ` borg launch <drone-label-or-id-prefix> Reopen one registered drone from its worktree\n` +
188
232
  ` borg launch-all [cube] Launch all drone worktrees of a cube (default: active cube)\n` +
233
+ ` borg representative prepare|status|mcp Let an MCP host (e.g. Hermes) speak for you to one Coordinator drone\n` +
189
234
  ` borg server <command> [arguments]\n` +
190
235
  ` borg --cli claude|codex|opencode Launch that agent CLI directly\n` +
191
236
  ` borg --version Show installed version\n\n` +
@@ -15,6 +15,7 @@ const REPOSITORY_URL = "https://github.com/Byte-Ventures/borg-mcp-client";
15
15
  const LOCAL_SERVER_URL = `${REPOSITORY_URL}/blob/main/docs/LOCAL_SERVER.md`;
16
16
  const SEAT_LIFECYCLE_URL = `${REPOSITORY_URL}/blob/main/docs/SEAT_LIFECYCLE.md`;
17
17
  const DOCUMENTS_URL = `${REPOSITORY_URL}/blob/main/docs/DOCUMENTS.md`;
18
+ const HUMAN_REPRESENTATIVE_URL = `${REPOSITORY_URL}/blob/main/docs/HUMAN_REPRESENTATIVE.md`;
18
19
 
19
20
  export interface DocsSection {
20
21
  /** logical topic key */
@@ -91,6 +92,13 @@ export const DOCS_SECTIONS: DocsSection[] = [
91
92
  summary: "Immutable cube-local Markdown or plain text, revisions, removal, and structured activity-log citations.",
92
93
  keywords: ["document", "documents", "citation", "cite", "durable content", "supersede", "revision", "borg_put-document", "borg_get-document"],
93
94
  },
95
+ {
96
+ slug: "human-representative",
97
+ title: "Human representative",
98
+ url: HUMAN_REPRESENTATIVE_URL,
99
+ summary: "A separate non-human-seat drone that relays the human's requests and decisions to one bound Coordinator over a restricted stdio MCP facade: prepare, host configuration, idempotent sends, no background wake.",
100
+ keywords: ["representative", "hermes", "human representative", "delegate", "proxy", "borg representative", "borg_representative-send", "mcp host", "request_id", "ambiguous"],
101
+ },
94
102
  {
95
103
  slug: "tools",
96
104
  title: "Tool reference",