borgmcp 2.3.0 → 2.4.1
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.
- package/README.md +3 -0
- package/dist/assimilate-cmd.d.ts +6 -0
- package/dist/assimilate-cmd.d.ts.map +1 -1
- package/dist/assimilate-cmd.js +31 -7
- package/dist/assimilate-cmd.js.map +1 -1
- package/dist/assimilate-deps.d.ts.map +1 -1
- package/dist/assimilate-deps.js +2 -0
- package/dist/assimilate-deps.js.map +1 -1
- package/dist/claude.d.ts.map +1 -1
- package/dist/claude.js +10 -0
- package/dist/claude.js.map +1 -1
- package/dist/cli-help.d.ts.map +1 -1
- package/dist/cli-help.js +2 -0
- package/dist/cli-help.js.map +1 -1
- package/dist/cli-platform.d.ts +1 -0
- package/dist/cli-platform.d.ts.map +1 -1
- package/dist/cli-platform.js +5 -1
- package/dist/cli-platform.js.map +1 -1
- package/dist/console-prefix.d.ts +9 -10
- package/dist/console-prefix.d.ts.map +1 -1
- package/dist/console-prefix.js +23 -20
- package/dist/console-prefix.js.map +1 -1
- package/dist/cubes.d.ts +11 -0
- package/dist/cubes.d.ts.map +1 -1
- package/dist/cubes.js +10 -0
- package/dist/cubes.js.map +1 -1
- package/dist/display-identity.d.ts +34 -0
- package/dist/display-identity.d.ts.map +1 -0
- package/dist/display-identity.js +88 -0
- package/dist/display-identity.js.map +1 -0
- package/dist/docs-sections.d.ts.map +1 -1
- package/dist/docs-sections.js +9 -0
- package/dist/docs-sections.js.map +1 -1
- package/dist/drone-management.d.ts.map +1 -1
- package/dist/drone-management.js +6 -0
- package/dist/drone-management.js.map +1 -1
- package/dist/inbox-monitor.d.ts.map +1 -1
- package/dist/inbox-monitor.js +12 -0
- package/dist/inbox-monitor.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +58 -16
- package/dist/index.js.map +1 -1
- package/dist/log-stream.d.ts.map +1 -1
- package/dist/log-stream.js +41 -15
- package/dist/log-stream.js.map +1 -1
- package/dist/parse-assimilate-args.d.ts +1 -1
- package/dist/parse-assimilate-args.d.ts.map +1 -1
- package/dist/parse-assimilate-args.js +5 -2
- package/dist/parse-assimilate-args.js.map +1 -1
- package/dist/regen-format.d.ts +2 -2
- package/dist/regen-format.d.ts.map +1 -1
- package/dist/regen-format.js +6 -5
- package/dist/regen-format.js.map +1 -1
- package/dist/regen.js +6 -2
- package/dist/regen.js.map +1 -1
- package/dist/remote-client.d.ts.map +1 -1
- package/dist/remote-client.js +8 -1
- package/dist/remote-client.js.map +1 -1
- package/dist/reset-local-seat-cmd.d.ts +8 -0
- package/dist/reset-local-seat-cmd.d.ts.map +1 -1
- package/dist/reset-local-seat-cmd.js +21 -2
- package/dist/reset-local-seat-cmd.js.map +1 -1
- package/dist/seat-reattach-guard.d.ts +21 -0
- package/dist/seat-reattach-guard.d.ts.map +1 -0
- package/dist/seat-reattach-guard.js +83 -0
- package/dist/seat-reattach-guard.js.map +1 -0
- package/dist/seats.d.ts +6 -2
- package/dist/seats.d.ts.map +1 -1
- package/dist/seats.js +37 -14
- package/dist/seats.js.map +1 -1
- package/dist/update-cmd.d.ts.map +1 -1
- package/dist/update-cmd.js +38 -61
- package/dist/update-cmd.js.map +1 -1
- package/docs/EXTRACTION_PROVENANCE.md +3 -3
- package/docs/LOCAL_SERVER.md +4 -0
- package/docs/RELEASING.md +28 -4
- package/docs/SEAT_LIFECYCLE.md +242 -0
- package/package.json +1 -1
- package/src/assimilate-cmd.ts +45 -7
- package/src/assimilate-deps.ts +2 -0
- package/src/claude.ts +11 -0
- package/src/cli-help.ts +2 -0
- package/src/cli-platform.ts +5 -2
- package/src/console-prefix.ts +25 -18
- package/src/cubes.ts +16 -0
- package/src/display-identity.ts +130 -0
- package/src/docs-sections.ts +9 -0
- package/src/drone-management.ts +6 -0
- package/src/inbox-monitor.ts +19 -0
- package/src/index.ts +66 -20
- package/src/log-stream.ts +38 -14
- package/src/parse-assimilate-args.ts +4 -2
- package/src/regen-format.ts +6 -5
- package/src/regen.ts +11 -2
- package/src/remote-client.ts +8 -0
- package/src/reset-local-seat-cmd.ts +26 -1
- package/src/seat-reattach-guard.ts +113 -0
- package/src/seats.ts +36 -14
- package/src/update-cmd.ts +36 -58
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
# Seat Lifecycle and Recovery
|
|
2
|
+
|
|
3
|
+
This guide describes the local seat behavior shipped by the client. A seat is
|
|
4
|
+
one server-side drone identity plus its client-generated session bearer and
|
|
5
|
+
worktree binding. The bearer and binding are stored together in the private
|
|
6
|
+
`seats.json` store; commands never reconstruct one from the other.
|
|
7
|
+
|
|
8
|
+
## Stored states
|
|
9
|
+
|
|
10
|
+
Only two seat states are persisted:
|
|
11
|
+
|
|
12
|
+
| State | Meaning | What can use it |
|
|
13
|
+
| --- | --- | --- |
|
|
14
|
+
| `pending` | The client persisted a fresh bearer before attach completed. It may be unbound, or bound to a worktree after a recoverable finalize failure. | Retry logic only. It is never hydrated as a live seat. |
|
|
15
|
+
| `active` | Attach completed and the server session metadata, display metadata, and worktree binding were committed together. | Normal coordination and re-attach. |
|
|
16
|
+
|
|
17
|
+
`revoked`, `superseded`, and `evicted` are server verdicts, not values written
|
|
18
|
+
to `seats.json`. Revocation and supersession do not silently delete local
|
|
19
|
+
state. An authoritative eviction is the sole verdict that permits the terminal
|
|
20
|
+
assimilation flow to replace a rejected seat. `borg reset-local-seat` is the
|
|
21
|
+
explicit offline deletion path.
|
|
22
|
+
|
|
23
|
+
Seat sessions do not expire. A legacy `expiresAt` field is ignored, and current
|
|
24
|
+
attach responses containing `expires_at` are rejected. An enrollment
|
|
25
|
+
invitation can expire; recovery is to request a new invitation. An expired
|
|
26
|
+
stream cursor is also separate from seat identity and is recovered by resetting
|
|
27
|
+
the cursor and reconnecting.
|
|
28
|
+
|
|
29
|
+
The whole seat store fails closed if any persisted record is malformed. The
|
|
30
|
+
store reader raises:
|
|
31
|
+
|
|
32
|
+
```text
|
|
33
|
+
Borg seat store is malformed or has an unsupported version; refusing to read it
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The file is not rewritten and no bearer material is included in the error.
|
|
37
|
+
|
|
38
|
+
## Enrollment, attach, and retry
|
|
39
|
+
|
|
40
|
+
Enrollment establishes the authority-bound parent credential used to request
|
|
41
|
+
seat attachment. Invitation enrollment persists its retry tuple before network
|
|
42
|
+
I/O; a rejected or expired invitation requires a new invitation.
|
|
43
|
+
|
|
44
|
+
For a seat attach, the client:
|
|
45
|
+
|
|
46
|
+
1. Mints a 32-byte base64url session bearer and persists a `pending` record
|
|
47
|
+
before sending it.
|
|
48
|
+
2. Sends that bearer to the server. The server stores only its digest.
|
|
49
|
+
3. On success, atomically changes the same record to `active` and adds the
|
|
50
|
+
server metadata and worktree binding.
|
|
51
|
+
|
|
52
|
+
An ambiguous retry reuses the same persisted bearer, so the server resolves the
|
|
53
|
+
same seat instead of minting a duplicate. A crash after server acceptance but
|
|
54
|
+
before local finalization is also resumed from that pending record. If a
|
|
55
|
+
sibling worktree was already created when activation failed, Borg binds the
|
|
56
|
+
still-pending record to that worktree without making it live; rerunning from
|
|
57
|
+
there resends the same bearer.
|
|
58
|
+
|
|
59
|
+
Only an `implicit-sibling:<id>` pending operation is eligible for automatic
|
|
60
|
+
implicit retry adoption. A `named-sibling:<name>` operation remains distinct
|
|
61
|
+
and is never silently adopted by an unnamed sibling launch.
|
|
62
|
+
|
|
63
|
+
If activation fails and the exact pending record is successfully bound to the
|
|
64
|
+
spawned worktree, Borg preserves that worktree and the printed retry reuses the
|
|
65
|
+
same seat. If the record is missing, replaced, or unavailable, Borg removes the
|
|
66
|
+
spawned worktree and does not print a client retry command: the server may
|
|
67
|
+
already have accepted the seat, and the current protocol has no client-side
|
|
68
|
+
operation identifier or cleanup endpoint that can prove reuse or remove it.
|
|
69
|
+
|
|
70
|
+
## Re-attaching from a terminal
|
|
71
|
+
|
|
72
|
+
`borg assimilate --here` first resolves this worktree's saved active seat and
|
|
73
|
+
probes it. A live seat is reattached with the identical bearer; its saved role
|
|
74
|
+
is authoritative, and no new drone is minted. The success line is:
|
|
75
|
+
|
|
76
|
+
```text
|
|
77
|
+
re-attached to existing seat <drone-label> (same session, no new drone minted)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Before relaunch, Borg checks the seat's inbox-monitor PID. A live holder refuses
|
|
81
|
+
the relaunch, names its PID, and gives both the safe fresh-worktree path and the
|
|
82
|
+
explicit one-time override:
|
|
83
|
+
|
|
84
|
+
```text
|
|
85
|
+
This worktree's Borg seat already has a live session (inbox monitor pid <pid>).
|
|
86
|
+
No agent was launched. Stop the existing session or use a fresh worktree with `borg assimilate --worktree <name>`. If the live monitor is wedged, override once with `borg assimilate --here --force`.
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
For a stale or missing heartbeat, the first line adds:
|
|
90
|
+
|
|
91
|
+
```text
|
|
92
|
+
Its heartbeat is <stale|missing>, so the process may be wedged.
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
A dead, absent, malformed, or unreadable PID file is not evidence of a live
|
|
96
|
+
holder, so ordinary crash recovery proceeds without `--force`. The probe is
|
|
97
|
+
read-only; it does not reap, kill, rewrite, or clear monitor state.
|
|
98
|
+
|
|
99
|
+
If a second monitor reaches the singleton after launch, it exits successfully
|
|
100
|
+
without tailing and prints:
|
|
101
|
+
|
|
102
|
+
```text
|
|
103
|
+
borg-inbox-monitor: seat inbox "<path>" is already monitored by a live instance (pid <pid>); yielding — another session likely holds this seat.
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
The existing monitor remains the sole inbox reader.
|
|
107
|
+
|
|
108
|
+
## Re-attaching from an agent session
|
|
109
|
+
|
|
110
|
+
The `borg_assimilate` MCP tool is re-attach-only. It can reuse this worktree's
|
|
111
|
+
saved seat for the requested cube, but it cannot create a seat or switch the
|
|
112
|
+
worktree to another cube.
|
|
113
|
+
|
|
114
|
+
With no saved identity, it returns:
|
|
115
|
+
|
|
116
|
+
```text
|
|
117
|
+
◼ This session has no drone seat for this worktree, and in-session borg_assimilate is re-attach-only (it never creates seats — gh#780). To create a seat for cube "<cube>", run `borg assimilate` in a terminal — it spawns the worktree, persists the identity, and launches the agent in one step.
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
For another cube, it returns:
|
|
121
|
+
|
|
122
|
+
```text
|
|
123
|
+
◼ This worktree is attached to cube "<active-cube>"; in-session borg_assimilate is re-attach-only and cannot switch to "<requested-cube>" (gh#780). To work in "<requested-cube>", run `borg assimilate` in a terminal from that project (or spawn a fresh worktree for it).
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
A successful in-session re-attach begins:
|
|
127
|
+
|
|
128
|
+
```text
|
|
129
|
+
# Re-attached to cube: <cube>
|
|
130
|
+
|
|
131
|
+
**Drone label:** <drone-label>
|
|
132
|
+
**Seat:** existing identity reused — no new drone minted (gh#780)
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
If the server no longer accepts the saved seat, the tool fails rather than
|
|
136
|
+
reminting:
|
|
137
|
+
|
|
138
|
+
```text
|
|
139
|
+
◼ Re-attach failed — this worktree's saved seat is unreachable (likely evicted or its session was revoked). Server said: <server-error>
|
|
140
|
+
Recover by running `borg assimilate` in a terminal to create a fresh seat; in-session borg_assimilate never re-mints (gh#780).
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Terminal verdicts and recovery
|
|
144
|
+
|
|
145
|
+
### Revoked or superseded session
|
|
146
|
+
|
|
147
|
+
A pin-matched `SESSION_REVOKED` or `SESSION_REJECTED` is diagnosis only. Attach
|
|
148
|
+
does not mutate the saved seat. The exact output is one of:
|
|
149
|
+
|
|
150
|
+
```text
|
|
151
|
+
Local session was revoked.
|
|
152
|
+
Next: run borg reset-local-seat, then borg assimilate --host <server> --enroll.
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
```text
|
|
156
|
+
Local session was superseded by a newer enrollment.
|
|
157
|
+
Next: run borg reset-local-seat, then borg assimilate --host <server> --enroll.
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Run the named offline reset, obtain a new invitation, and enroll again.
|
|
161
|
+
|
|
162
|
+
### Evicted seat
|
|
163
|
+
|
|
164
|
+
Only an authoritative `410 DRONE_EVICTED` permits replacement of a rejected
|
|
165
|
+
saved seat. If the pre-attach probe establishes eviction, the terminal
|
|
166
|
+
assimilation flow may replace the bearer and attach a fresh seat. Ambiguous
|
|
167
|
+
transport, trust, endpoint, credential, or server failures never authorize that
|
|
168
|
+
replacement.
|
|
169
|
+
|
|
170
|
+
If the attach itself returns the eviction after re-attach began, Borg does not
|
|
171
|
+
claim recovery. It prints:
|
|
172
|
+
|
|
173
|
+
```text
|
|
174
|
+
This worktree's saved seat on <server> was evicted. Remove this worktree, or from a fresh worktree run `borg assimilate --host <server>`.
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
The stream path also treats typed `DRONE_EVICTED` as terminal: it marks that
|
|
178
|
+
exact local candidate rejected, stops retrying that seat, and does not restart
|
|
179
|
+
the loop as though the failure were transient.
|
|
180
|
+
|
|
181
|
+
### Rejected or expired invitation
|
|
182
|
+
|
|
183
|
+
An invitation failure is enrollment recovery, not a seat reset:
|
|
184
|
+
|
|
185
|
+
```text
|
|
186
|
+
The enrollment invitation for <server> was rejected or expired. Ask the server operator for a replacement invitation — the server can stay running: for an unclaimed owner client run `borg-mcp-server owner-invite`; for an ordinary client run `borg-mcp-server client-invite`. Then rerun `borg assimilate --host <server> --enroll`.
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
## Offline reset
|
|
190
|
+
|
|
191
|
+
`borg reset-local-seat` clears only the current worktree's saved credential and
|
|
192
|
+
cube binding. It makes no network call, revokes nothing server-side, and leaves
|
|
193
|
+
the server, trust anchor, cube, and sibling worktrees unchanged.
|
|
194
|
+
|
|
195
|
+
Its shipped help describes the command as:
|
|
196
|
+
|
|
197
|
+
```text
|
|
198
|
+
borg reset-local-seat (borgmcp <version>) — clear ONLY this worktree's saved local seat
|
|
199
|
+
|
|
200
|
+
Usage:
|
|
201
|
+
borg reset-local-seat Reset this worktree's saved seat (TTY confirms [y/N])
|
|
202
|
+
borg reset-local-seat --host <host> No-op unless this worktree's seat is on <host>
|
|
203
|
+
borg reset-local-seat --yes Reset without a prompt (required when non-interactive)
|
|
204
|
+
borg reset-local-seat --help Show this help
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
The command snapshots the exact binding and token-safe bearer observation,
|
|
208
|
+
prompts outside the store lock, then revalidates the same seat before deleting
|
|
209
|
+
it. If another process replaced or reset the seat, the command is an honest
|
|
210
|
+
no-op rather than deleting the successor. After a successful reset, Borg reads
|
|
211
|
+
the same worktree again without hydrating any bearer. If another saved active
|
|
212
|
+
seat remains on the same server, the command points to
|
|
213
|
+
`borg assimilate --host <server> --here` and states that the server will
|
|
214
|
+
revalidate it before launch. Otherwise it gives the fresh-enrollment path:
|
|
215
|
+
|
|
216
|
+
```text
|
|
217
|
+
borg assimilate --host <server> --enroll
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
## Multiple seats and deterministic selection
|
|
221
|
+
|
|
222
|
+
An in-place seat uses operation kind `seat` and operation key
|
|
223
|
+
`current-worktree`. A sibling uses operation kind `sibling`; named siblings key
|
|
224
|
+
on their worktree name, while implicit siblings receive a unique operation key.
|
|
225
|
+
These operations derive distinct credential references and distinct bearers.
|
|
226
|
+
Creating a sibling therefore never moves or overwrites the original
|
|
227
|
+
worktree's active seat.
|
|
228
|
+
|
|
229
|
+
Historical recovery or interrupted operations can leave more than one active
|
|
230
|
+
record bound to one worktree. Every process uses the same total order:
|
|
231
|
+
|
|
232
|
+
1. A candidate not definitively rejected in this process comes before a
|
|
233
|
+
rejected, revoked, or evicted candidate.
|
|
234
|
+
2. A `sibling` candidate finalized into this worktree comes before an older
|
|
235
|
+
in-place `seat` candidate.
|
|
236
|
+
3. Remaining ties use the credential reference's lexical order.
|
|
237
|
+
|
|
238
|
+
Only `active` records participate in normal selection. A bound `pending` record
|
|
239
|
+
is discoverable only by the convergence path that resends its exact bearer.
|
|
240
|
+
This ordering is shared by SessionStart, kickoff construction, MCP children,
|
|
241
|
+
stream ownership, and normal active-cube hydration, so one process does not
|
|
242
|
+
select different seats for different surfaces.
|
package/package.json
CHANGED
package/src/assimilate-cmd.ts
CHANGED
|
@@ -28,6 +28,11 @@ import {
|
|
|
28
28
|
import type { BorgCli } from './cubes.js';
|
|
29
29
|
import { inboxPathForDrone } from './cubes.js';
|
|
30
30
|
import { monitorStateRootForWorktree } from './inbox-monitor.js';
|
|
31
|
+
import {
|
|
32
|
+
formatSeatReattachRefusal,
|
|
33
|
+
inspectLiveInboxMonitor,
|
|
34
|
+
type LiveInboxMonitor,
|
|
35
|
+
} from './seat-reattach-guard.js';
|
|
31
36
|
import { resolveLaunchEnv } from './model-presets.js';
|
|
32
37
|
import { unlinkSync } from 'node:fs';
|
|
33
38
|
import {
|
|
@@ -89,6 +94,7 @@ export interface AssimilateFlags {
|
|
|
89
94
|
model?: string;
|
|
90
95
|
server?: string;
|
|
91
96
|
enroll?: boolean;
|
|
97
|
+
force?: boolean;
|
|
92
98
|
}
|
|
93
99
|
|
|
94
100
|
export interface AssimilateArgs {
|
|
@@ -154,6 +160,8 @@ export interface ActiveCube {
|
|
|
154
160
|
/** Verified local-server CA identity; absent until a local server is selected. */
|
|
155
161
|
serverTrustIdentity?: string;
|
|
156
162
|
localSessionCredentialRef?: string;
|
|
163
|
+
/** Durable operation that produced this exact seat binding. */
|
|
164
|
+
operation?: ServerSessionOperation;
|
|
157
165
|
// gh#899: assimilated role, persisted for connect-time tool-surface scoping
|
|
158
166
|
// (mirrors cubes.ts ActiveCube; optional → backward-compatible).
|
|
159
167
|
roleName?: string;
|
|
@@ -202,6 +210,11 @@ export interface AssimilateDeps {
|
|
|
202
210
|
// The scaffold previously declared them sync; that would silently
|
|
203
211
|
// mis-await in Phase F wiring. Promise<...> matches the real shape.
|
|
204
212
|
getActiveCube: () => Promise<ActiveCube | null>;
|
|
213
|
+
/** Read-only relaunch guard for the saved seat's inbox monitor. */
|
|
214
|
+
inspectLiveInboxMonitor?: (
|
|
215
|
+
inboxPath: string,
|
|
216
|
+
monitorStateRoot: string,
|
|
217
|
+
) => LiveInboxMonitor | null;
|
|
205
218
|
hasPersistedActiveCube: () => Promise<boolean>;
|
|
206
219
|
/** Read the RAW persisted local seat for this worktree WITHOUT hydrating its
|
|
207
220
|
* keychain credential — used to recover a crash-in-gap PENDING seat when
|
|
@@ -848,6 +861,12 @@ export async function runAssimilate(
|
|
|
848
861
|
: `named-sibling:${args.flags.worktree}`)
|
|
849
862
|
: 'current-worktree',
|
|
850
863
|
};
|
|
864
|
+
// A selected sibling can be the surviving live seat for this worktree (#63).
|
|
865
|
+
// `--here` must re-send that seat's durable operation, not reconstruct the
|
|
866
|
+
// in-place operation and then fail PREPARE forever on the wrong ref.
|
|
867
|
+
if (existing && args.flags.here && existing.operation) {
|
|
868
|
+
sessionOperation = existing.operation;
|
|
869
|
+
}
|
|
851
870
|
let reattachPriorId: string | undefined;
|
|
852
871
|
let remintInvalidPrior = false;
|
|
853
872
|
let savedLocalRole: Role | undefined;
|
|
@@ -1083,6 +1102,16 @@ export async function runAssimilate(
|
|
|
1083
1102
|
reattachPriorId = existing.droneId;
|
|
1084
1103
|
}
|
|
1085
1104
|
|
|
1105
|
+
if (existing && reattachPriorId !== undefined && !args.flags.force) {
|
|
1106
|
+
const inboxPath = deps.getInboxPath(existing.cubeId, existing.droneId);
|
|
1107
|
+
const stateRoot = monitorStateRootForWorktree(projectRoot);
|
|
1108
|
+
const holder = (deps.inspectLiveInboxMonitor ?? inspectLiveInboxMonitor)(inboxPath, stateRoot);
|
|
1109
|
+
if (holder !== null) {
|
|
1110
|
+
deps.stderr(formatSeatReattachRefusal(holder, 'borg assimilate --here --force'));
|
|
1111
|
+
return 1;
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1086
1115
|
// ----- Step 5: Role resolution -----
|
|
1087
1116
|
let resolvedRole: Role | undefined;
|
|
1088
1117
|
if (savedLocalRole) {
|
|
@@ -1545,14 +1574,23 @@ export async function runAssimilate(
|
|
|
1545
1574
|
return 1;
|
|
1546
1575
|
}
|
|
1547
1576
|
// missing / replaced / threw / unavailable: the worktree owns NO durable
|
|
1548
|
-
// locator
|
|
1549
|
-
//
|
|
1577
|
+
// locator. The server may already have accepted the seat, while the client
|
|
1578
|
+
// has no protocol operation id or cleanup endpoint with which to prove reuse
|
|
1579
|
+
// or remove it. State the exact local outcome and do not prescribe a retry
|
|
1580
|
+
// that can silently create a duplicate server seat (#35).
|
|
1581
|
+
const bindFailure =
|
|
1582
|
+
bindOutcome === 'missing'
|
|
1583
|
+
? 'the exact pending seat record went missing locally before it could be bound'
|
|
1584
|
+
: bindOutcome === 'replaced'
|
|
1585
|
+
? 'the exact pending seat record was replaced locally before it could be bound; the replacement was left untouched'
|
|
1586
|
+
: bindOutcome === 'threw'
|
|
1587
|
+
? 'the local seat store could not be read or written while preserving the pending seat'
|
|
1588
|
+
: 'this client did not receive a pending-seat preservation handle';
|
|
1550
1589
|
deps.stderr(
|
|
1551
|
-
`This worktree's secure session on ${auth.apiUrl} did not finish activating
|
|
1552
|
-
|
|
1553
|
-
'
|
|
1554
|
-
|
|
1555
|
-
`${localAssimilateCommand(auth.apiUrl)} to attach against the current state.\n`,
|
|
1590
|
+
`This worktree's secure session on ${auth.apiUrl} did not finish activating: ` +
|
|
1591
|
+
`${bindFailure}. The spawned worktree will be removed. No client-only command can ` +
|
|
1592
|
+
'prove reuse or safely clear the possibly accepted server-side seat; ask the server ' +
|
|
1593
|
+
'operator to inspect that seat before retrying.\n',
|
|
1556
1594
|
);
|
|
1557
1595
|
rollbackWorktree();
|
|
1558
1596
|
return 1;
|
package/src/assimilate-deps.ts
CHANGED
|
@@ -17,6 +17,7 @@ import prompts from 'prompts';
|
|
|
17
17
|
import { readinessProbeEnv } from './readiness-probe.js';
|
|
18
18
|
import { resolveMcpBinaryPath } from './self-path.js';
|
|
19
19
|
import { buildRuntimeMetadataReport } from './runtime-metadata.js';
|
|
20
|
+
import { inspectLiveInboxMonitor } from './seat-reattach-guard.js';
|
|
20
21
|
|
|
21
22
|
import type { AssimilateDeps } from './assimilate-cmd.js';
|
|
22
23
|
import {
|
|
@@ -170,6 +171,7 @@ export function buildDefaultAssimilateDeps(
|
|
|
170
171
|
},
|
|
171
172
|
|
|
172
173
|
getActiveCube: () => cubesGetActive(),
|
|
174
|
+
inspectLiveInboxMonitor,
|
|
173
175
|
hasPersistedActiveCube: () => cubesHasPersistedActive(),
|
|
174
176
|
readPersistedLocalSeat: () => readPersistedLocalSeat(),
|
|
175
177
|
peekServerSessionRecord: async (credentialRef, binding) =>
|
package/src/claude.ts
CHANGED
|
@@ -26,6 +26,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
26
26
|
import chalk from 'chalk';
|
|
27
27
|
import { findProjectRoot, getActiveCube, inboxPathForDrone, setCodexWakeTarget, pruneDeadCodexWakeTargets } from './cubes.js';
|
|
28
28
|
import { monitorStateRootForWorktree } from './inbox-monitor.js';
|
|
29
|
+
import { formatSeatReattachRefusal, inspectLiveInboxMonitor } from './seat-reattach-guard.js';
|
|
29
30
|
import { handleVersionFlag, getPackageVersion } from './version.js';
|
|
30
31
|
import { isHelpFlag, setupHelpText, topLevelHelpText, assimilateHelpText, resetLocalSeatHelpText } from './cli-help.js';
|
|
31
32
|
import { runSpawn } from './spawn.js';
|
|
@@ -310,6 +311,16 @@ async function main() {
|
|
|
310
311
|
cli = action.cli;
|
|
311
312
|
}
|
|
312
313
|
|
|
314
|
+
if (active && !parsedCli.force) {
|
|
315
|
+
const inboxPath = inboxPathForDrone(active.cubeId, active.droneId);
|
|
316
|
+
const stateRoot = monitorStateRootForWorktree(findProjectRoot(process.cwd()));
|
|
317
|
+
const holder = inspectLiveInboxMonitor(inboxPath, stateRoot);
|
|
318
|
+
if (holder !== null) {
|
|
319
|
+
process.stderr.write(formatSeatReattachRefusal(holder, 'borg --force'));
|
|
320
|
+
process.exit(1);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
313
324
|
// client#20: inspect only the SELECTED harness after the one-shot launch
|
|
314
325
|
// menu choice. Explicit consent enables a narrow per-process override;
|
|
315
326
|
// Borg never rewrites the user's approval policy here.
|
package/src/cli-help.ts
CHANGED
|
@@ -109,12 +109,14 @@ export function assimilateHelpText(version: string): string {
|
|
|
109
109
|
` borg assimilate [role] --worktree <name> Spawn the drone in an isolated git worktree\n` +
|
|
110
110
|
` (~/.borg/worktrees/<repo>/<name>)\n` +
|
|
111
111
|
` borg assimilate --here Assimilate in the current worktree (no sibling spawn)\n` +
|
|
112
|
+
` borg assimilate --here --force Reattach despite a still-live inbox monitor\n` +
|
|
112
113
|
` borg assimilate --host <host> Join an authorized self-hosted cube\n` +
|
|
113
114
|
` borg assimilate --host <host> --enroll Operator-terminal enrollment, then create/join (preview)\n` +
|
|
114
115
|
` borg assimilate --help Show this help\n\n` +
|
|
115
116
|
`Flags:\n` +
|
|
116
117
|
` --worktree <name> Create + launch the drone in a sibling git worktree\n` +
|
|
117
118
|
` --here Stay in the current worktree (no sibling spawn)\n` +
|
|
119
|
+
` --force Override a live-seat guard for a wedged monitor\n` +
|
|
118
120
|
` --cube-name <name> Repository cube name (otherwise edit the proposed name)\n` +
|
|
119
121
|
` --host <host> Borg server host or URL (bare hosts default to HTTPS)\n` +
|
|
120
122
|
` --enroll Prompt for a hidden enrollment invitation in the operator terminal\n` +
|
package/src/cli-platform.ts
CHANGED
|
@@ -101,9 +101,10 @@ export function defaultCliChoiceDeps(prompt: (message: string) => Promise<string
|
|
|
101
101
|
|
|
102
102
|
const VALID_CLIS: readonly BorgCli[] = ['claude', 'codex', 'opencode'];
|
|
103
103
|
|
|
104
|
-
export function parseCliFlag(args: string[]): { cli?: BorgCli; rest: string[]; error?: string } {
|
|
104
|
+
export function parseCliFlag(args: string[]): { cli?: BorgCli; force?: boolean; rest: string[]; error?: string } {
|
|
105
105
|
const rest: string[] = [];
|
|
106
106
|
let cli: BorgCli | undefined;
|
|
107
|
+
let force = false;
|
|
107
108
|
for (let i = 0; i < args.length; i++) {
|
|
108
109
|
const arg = args[i];
|
|
109
110
|
if (arg === '--cli') {
|
|
@@ -119,9 +120,11 @@ export function parseCliFlag(args: string[]): { cli?: BorgCli; rest: string[]; e
|
|
|
119
120
|
return { rest, error: `--cli requires one of: ${VALID_CLIS.join(', ')}` };
|
|
120
121
|
}
|
|
121
122
|
cli = value as BorgCli;
|
|
123
|
+
} else if (arg === '--force') {
|
|
124
|
+
force = true;
|
|
122
125
|
} else {
|
|
123
126
|
rest.push(arg);
|
|
124
127
|
}
|
|
125
128
|
}
|
|
126
|
-
return { cli, rest };
|
|
129
|
+
return { ...(cli ? { cli } : {}), ...(force ? { force: true } : {}), rest };
|
|
127
130
|
}
|
package/src/console-prefix.ts
CHANGED
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
* insufficient — the Queen reads the active terminal's output stream,
|
|
10
10
|
* not its title bar.
|
|
11
11
|
*
|
|
12
|
-
* This module exports a one-shot initializer that
|
|
13
|
-
* from the
|
|
14
|
-
*
|
|
12
|
+
* This module exports a one-shot initializer that seeds the process-local
|
|
13
|
+
* display identity from the selected seat, plus a synchronous getter that
|
|
14
|
+
* follows later server confirmations and wraps each console.error.
|
|
15
15
|
*
|
|
16
16
|
* Format (matches the terminal-title.ts middle-dot convention so
|
|
17
17
|
* surfaces stay internally consistent):
|
|
@@ -27,8 +27,13 @@
|
|
|
27
27
|
import { basename } from 'node:path';
|
|
28
28
|
import chalk from 'chalk';
|
|
29
29
|
import { getActiveCube } from './cubes.js';
|
|
30
|
+
import {
|
|
31
|
+
_resetDisplayIdentityForTests,
|
|
32
|
+
currentDisplayIdentity,
|
|
33
|
+
seedDisplayIdentity,
|
|
34
|
+
} from './display-identity.js';
|
|
30
35
|
|
|
31
|
-
let
|
|
36
|
+
let initialized = false;
|
|
32
37
|
|
|
33
38
|
/** Neutral prefix for a not-yet-assimilated session (gh#818 P1). */
|
|
34
39
|
function unassimilatedPrefix(): string {
|
|
@@ -36,34 +41,35 @@ function unassimilatedPrefix(): string {
|
|
|
36
41
|
}
|
|
37
42
|
|
|
38
43
|
/**
|
|
39
|
-
* Resolve the drone-self-identification prefix from cube state and
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
* blocked.
|
|
44
|
+
* Resolve the drone-self-identification prefix from cube state and seed the
|
|
45
|
+
* shared display source. Idempotent — later calls do not re-read the store,
|
|
46
|
+
* while the synchronous prefix still follows server-confirmed display changes.
|
|
47
|
+
* Falls back silently on any read error so console emission is never blocked.
|
|
44
48
|
*/
|
|
45
49
|
export async function initConsolePrefix(): Promise<string> {
|
|
46
|
-
if (
|
|
50
|
+
if (initialized) return droneIdPrefix();
|
|
47
51
|
try {
|
|
48
52
|
const active = await getActiveCube();
|
|
49
53
|
if (active?.droneLabel && active?.name) {
|
|
50
|
-
|
|
51
|
-
|
|
54
|
+
seedDisplayIdentity(active);
|
|
55
|
+
initialized = true;
|
|
56
|
+
return droneIdPrefix();
|
|
52
57
|
}
|
|
53
58
|
} catch {
|
|
54
59
|
// Fall through to unassimilated fallback.
|
|
55
60
|
}
|
|
56
|
-
|
|
57
|
-
return
|
|
61
|
+
initialized = true;
|
|
62
|
+
return unassimilatedPrefix();
|
|
58
63
|
}
|
|
59
64
|
|
|
60
65
|
/**
|
|
61
|
-
* Synchronous prefix getter. Returns the
|
|
62
|
-
* otherwise the unassimilated fallback — safe to call before
|
|
66
|
+
* Synchronous prefix getter. Returns the current process-local display value
|
|
67
|
+
* if initialized, otherwise the unassimilated fallback — safe to call before
|
|
63
68
|
* initConsolePrefix() resolves.
|
|
64
69
|
*/
|
|
65
70
|
export function droneIdPrefix(): string {
|
|
66
|
-
|
|
71
|
+
const identity = currentDisplayIdentity();
|
|
72
|
+
if (initialized && identity) return `[${identity.droneLabel} · ${identity.cubeName}]`;
|
|
67
73
|
return unassimilatedPrefix();
|
|
68
74
|
}
|
|
69
75
|
|
|
@@ -95,5 +101,6 @@ export function cerr(...args: any[]): void {
|
|
|
95
101
|
}
|
|
96
102
|
|
|
97
103
|
export function _resetCachedPrefixForTests(): void {
|
|
98
|
-
|
|
104
|
+
initialized = false;
|
|
105
|
+
_resetDisplayIdentityForTests();
|
|
99
106
|
}
|
package/src/cubes.ts
CHANGED
|
@@ -62,6 +62,8 @@ export interface ActiveCube {
|
|
|
62
62
|
serverTrustIdentity?: string;
|
|
63
63
|
/** Opaque local-session keychain reference; never a bearer. */
|
|
64
64
|
localSessionCredentialRef?: string;
|
|
65
|
+
/** Durable seat operation that produced this exact local binding. */
|
|
66
|
+
operation?: SeatOperation;
|
|
65
67
|
// gh#899: the assimilated role, persisted so the connect-time ListTools
|
|
66
68
|
// handler can role-scope the NATIVE tool surface (UX/context only — never an
|
|
67
69
|
// auth boundary). Absent on pre-gh#899 cubes.json entries → the filter
|
|
@@ -279,12 +281,26 @@ async function hydrateActiveCube(record: SeatRecord): Promise<ActiveCube | null>
|
|
|
279
281
|
apiUrl: record.origin,
|
|
280
282
|
serverTrustIdentity: record.trustIdentity,
|
|
281
283
|
localSessionCredentialRef: ref,
|
|
284
|
+
operation: record.operation,
|
|
282
285
|
...(record.roleName !== undefined ? { roleName: record.roleName } : {}),
|
|
283
286
|
...(record.roleClass !== undefined ? { roleClass: record.roleClass } : {}),
|
|
284
287
|
...(record.isHumanSeat !== undefined ? { isHumanSeat: record.isHumanSeat } : {}),
|
|
285
288
|
};
|
|
286
289
|
}
|
|
287
290
|
|
|
291
|
+
/**
|
|
292
|
+
* Token-free lookup used after an offline reset. A surviving seat is only
|
|
293
|
+
* described as saved local state; the caller must still revalidate it with the
|
|
294
|
+
* server before launch.
|
|
295
|
+
*/
|
|
296
|
+
export async function findRemainingActiveSeatForWorktree(worktree: string): Promise<{
|
|
297
|
+
apiUrl: string;
|
|
298
|
+
operation: SeatOperation;
|
|
299
|
+
} | null> {
|
|
300
|
+
const record = await getActiveSeatForWorktree(worktree);
|
|
301
|
+
return record ? { apiUrl: record.origin, operation: record.operation } : null;
|
|
302
|
+
}
|
|
303
|
+
|
|
288
304
|
/**
|
|
289
305
|
* Legacy binding-only writer. In the collapsed single-store model an ACTIVE seat is
|
|
290
306
|
* created ONLY by the atomic mint→activate+bind path in seats.ts (driven by the
|