borgmcp 5.4.1 → 5.6.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 (101) hide show
  1. package/README.md +15 -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 +30 -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 +45 -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 +10 -1
  17. package/dist/local-server-cursor.d.ts.map +1 -1
  18. package/dist/local-server-cursor.js +64 -5
  19. package/dist/local-server-cursor.js.map +1 -1
  20. package/dist/log-stream.d.ts +13 -0
  21. package/dist/log-stream.d.ts.map +1 -1
  22. package/dist/log-stream.js +45 -13
  23. package/dist/log-stream.js.map +1 -1
  24. package/dist/remote-client.d.ts +17 -0
  25. package/dist/remote-client.d.ts.map +1 -1
  26. package/dist/remote-client.js +37 -15
  27. package/dist/remote-client.js.map +1 -1
  28. package/dist/representative-cmd.d.ts +94 -0
  29. package/dist/representative-cmd.d.ts.map +1 -0
  30. package/dist/representative-cmd.js +290 -0
  31. package/dist/representative-cmd.js.map +1 -0
  32. package/dist/representative-core.d.ts +243 -0
  33. package/dist/representative-core.d.ts.map +1 -0
  34. package/dist/representative-core.js +679 -0
  35. package/dist/representative-core.js.map +1 -0
  36. package/dist/representative-delivery-store.d.ts +79 -0
  37. package/dist/representative-delivery-store.d.ts.map +1 -0
  38. package/dist/representative-delivery-store.js +233 -0
  39. package/dist/representative-delivery-store.js.map +1 -0
  40. package/dist/representative-listener-store.d.ts +46 -0
  41. package/dist/representative-listener-store.d.ts.map +1 -0
  42. package/dist/representative-listener-store.js +119 -0
  43. package/dist/representative-listener-store.js.map +1 -0
  44. package/dist/representative-listener.d.ts +32 -0
  45. package/dist/representative-listener.d.ts.map +1 -0
  46. package/dist/representative-listener.js +293 -0
  47. package/dist/representative-listener.js.map +1 -0
  48. package/dist/representative-mcp.d.ts +37 -0
  49. package/dist/representative-mcp.d.ts.map +1 -0
  50. package/dist/representative-mcp.js +211 -0
  51. package/dist/representative-mcp.js.map +1 -0
  52. package/dist/representative-owner.d.ts +10 -0
  53. package/dist/representative-owner.d.ts.map +1 -0
  54. package/dist/representative-owner.js +107 -0
  55. package/dist/representative-owner.js.map +1 -0
  56. package/dist/representative-store.d.ts +68 -0
  57. package/dist/representative-store.d.ts.map +1 -0
  58. package/dist/representative-store.js +173 -0
  59. package/dist/representative-store.js.map +1 -0
  60. package/dist/seat-probe.d.ts +1 -0
  61. package/dist/seat-probe.d.ts.map +1 -1
  62. package/dist/seat-probe.js +1 -1
  63. package/dist/seat-probe.js.map +1 -1
  64. package/dist/seat-store.d.ts +13 -0
  65. package/dist/seat-store.d.ts.map +1 -1
  66. package/dist/seat-store.js +55 -10
  67. package/dist/seat-store.js.map +1 -1
  68. package/dist/server-trust.d.ts +10 -0
  69. package/dist/server-trust.d.ts.map +1 -1
  70. package/dist/server-trust.js +23 -6
  71. package/dist/server-trust.js.map +1 -1
  72. package/dist/stream-owner.d.ts +10 -0
  73. package/dist/stream-owner.d.ts.map +1 -1
  74. package/dist/stream-owner.js +129 -21
  75. package/dist/stream-owner.js.map +1 -1
  76. package/dist/unknown-subcommand.d.ts +1 -1
  77. package/dist/unknown-subcommand.d.ts.map +1 -1
  78. package/dist/unknown-subcommand.js +1 -0
  79. package/dist/unknown-subcommand.js.map +1 -1
  80. package/docs/HUMAN_REPRESENTATIVE.md +434 -0
  81. package/package.json +1 -1
  82. package/src/assimilate-cmd.ts +73 -22
  83. package/src/claude.ts +30 -0
  84. package/src/cli-help.ts +48 -0
  85. package/src/docs-sections.ts +8 -0
  86. package/src/local-server-cursor.ts +56 -4
  87. package/src/log-stream.ts +47 -14
  88. package/src/remote-client.ts +54 -13
  89. package/src/representative-cmd.ts +369 -0
  90. package/src/representative-core.ts +908 -0
  91. package/src/representative-delivery-store.ts +235 -0
  92. package/src/representative-listener-store.ts +115 -0
  93. package/src/representative-listener.ts +215 -0
  94. package/src/representative-mcp.ts +250 -0
  95. package/src/representative-owner.ts +105 -0
  96. package/src/representative-store.ts +224 -0
  97. package/src/seat-probe.ts +1 -1
  98. package/src/seat-store.ts +61 -10
  99. package/src/server-trust.ts +25 -6
  100. package/src/stream-owner.ts +129 -20
  101. package/src/unknown-subcommand.ts +1 -0
@@ -0,0 +1,235 @@
1
+ /** Private per-binding DELIVERED checkpoint and read fence for the representative. */
2
+ import { createHash } from 'node:crypto';
3
+ import { constants } from 'node:fs';
4
+ import { lstat, open, readdir } from 'node:fs/promises';
5
+ import { join } from 'node:path';
6
+ import { borgConfigRoot } from './private-root.js';
7
+ import { atomicWrite0600, readStoreFile } from './seat-store.js';
8
+ import type { LocalServerCursor } from './local-server-cursor.js';
9
+ import { bindingFingerprint, isRepresentativeUuid, type RepresentativeBinding } from './representative-store.js';
10
+ import { validatePrivateDirectory } from './representative-listener-store.js';
11
+
12
+ /**
13
+ * `checkpoint`: the host's durable delivery point; only `deliver` moves it.
14
+ * `readThrough`: the highest entry any `read` returned; `deliver` may not pass it.
15
+ */
16
+ export interface DeliveryState {
17
+ checkpoint: LocalServerCursor | null;
18
+ readThrough: LocalServerCursor | null;
19
+ /**
20
+ * Entries a read returned since the checkpoint last moved: `deliver` checks
21
+ * membership here, not just the range. Every window starts at the
22
+ * checkpoint, so this stays within the largest window (two, with and without
23
+ * broadcasts), and deliver prunes it.
24
+ */
25
+ returned: LocalServerCursor[];
26
+ }
27
+
28
+ /** Two windows of at most 50 replies (with and without broadcasts). */
29
+ const RETURNED_CAP = 100;
30
+
31
+ const deliveryRoot = () => join(borgConfigRoot(), 'representative-delivery');
32
+
33
+ /**
34
+ * This binding's own checkpoint file exists but cannot be trusted. It is never
35
+ * used and never silently reset: every tool except status refuses until the
36
+ * operator inspects and removes it.
37
+ */
38
+ export class DeliveryCheckpointError extends Error {
39
+ readonly code = 'REPRESENTATIVE_CHECKPOINT_INVALID';
40
+ constructor(file: string, reason: string) {
41
+ super(`The representative delivery checkpoint ${file} is invalid (${reason}). Nothing was read or delivered. ` +
42
+ 'Inspect the file and remove it; the next read then replays every addressed reply from the start.');
43
+ this.name = 'DeliveryCheckpointError';
44
+ }
45
+ }
46
+
47
+ export function deliveryPaths(binding: RepresentativeBinding) {
48
+ const directory = join(deliveryRoot(), bindingFingerprint(binding));
49
+ return { directory, file: join(directory, 'checkpoint.json') };
50
+ }
51
+
52
+ /**
53
+ * The seat every generation of a binding shares: the same fields as the
54
+ * client unread-cursor key, without Coordinator or boundAt.
55
+ */
56
+ function seatKey(binding: RepresentativeBinding): string {
57
+ return createHash('sha256').update(JSON.stringify([
58
+ binding.origin, binding.trustIdentity, binding.cubeId, binding.representativeDroneId,
59
+ ])).digest('hex');
60
+ }
61
+
62
+ function point(value: unknown): LocalServerCursor | null {
63
+ if (value === null) return null;
64
+ const candidate = value as { id?: unknown; created_at?: unknown } | undefined;
65
+ if (!isRepresentativeUuid(candidate?.id) || typeof candidate?.created_at !== 'string' ||
66
+ !Number.isFinite(Date.parse(candidate.created_at))) {
67
+ throw new Error('Representative delivery checkpoint is invalid');
68
+ }
69
+ return { id: candidate.id, created_at: candidate.created_at };
70
+ }
71
+
72
+ /** (created_at, id) order, the server log order. */
73
+ export function comparePoints(a: LocalServerCursor, b: LocalServerCursor | null): number {
74
+ if (b === null) return 1;
75
+ if (a.created_at !== b.created_at) return a.created_at < b.created_at ? -1 : 1;
76
+ return a.id === b.id ? 0 : a.id < b.id ? -1 : 1;
77
+ }
78
+
79
+ async function readFile(directory: string, file: string): Promise<{ seat: string; state: DeliveryState } | null> {
80
+ if (!await validatePrivateDirectory(directory, false)) return null;
81
+ const raw = await readStoreFile(file, { secureRoot: directory, verifyLeafIdentity: true, createRoot: false });
82
+ if (raw === null) return null;
83
+ let parsed: { version?: unknown; seat?: unknown; checkpoint?: unknown; readThrough?: unknown; returned?: unknown };
84
+ try { parsed = JSON.parse(raw); } catch { throw new Error('Representative delivery checkpoint is invalid'); }
85
+ if (parsed?.version !== 1 || typeof parsed.seat !== 'string') throw new Error('Representative delivery checkpoint is invalid');
86
+ const returned = parsed.returned ?? [];
87
+ if (!Array.isArray(returned) || returned.length > RETURNED_CAP) throw new Error('Representative delivery checkpoint is invalid');
88
+ return { seat: parsed.seat, state: {
89
+ checkpoint: point(parsed.checkpoint), readThrough: point(parsed.readThrough),
90
+ returned: returned.map((value) => point(value)!).map((value) => { if (!value) throw new Error('Representative delivery checkpoint is invalid'); return value; }),
91
+ } };
92
+ }
93
+
94
+ // One queue per state file: overlapping tool calls in one process never write
95
+ // from a stale load. Other processes are excluded by the tools lease.
96
+ const queues = new Map<string, Promise<unknown>>();
97
+
98
+ const later = (a: LocalServerCursor | null, b: LocalServerCursor | null) =>
99
+ a === null ? b : b === null ? a : comparePoints(a, b) >= 0 ? a : b;
100
+
101
+ /**
102
+ * The one-time upgrade tombstone for a seat. Its existence alone means the
103
+ * legacy import was attempted; nothing in it is ever read back as a position.
104
+ * Its directory name is not 64 hex characters, so the generation scan never
105
+ * mistakes it for a checkpoint.
106
+ */
107
+ export function createDeliveryStore(binding: RepresentativeBinding) {
108
+ const paths = deliveryPaths(binding);
109
+ const seat = seatKey(binding);
110
+ const marker = { directory: join(deliveryRoot(), `seat-${seat}`), file: join(deliveryRoot(), `seat-${seat}`, 'migration.json') };
111
+ const options = { secureRoot: paths.directory, verifyLeafIdentity: true, createRoot: false };
112
+ const load = async (): Promise<DeliveryState | null> => {
113
+ let saved;
114
+ try {
115
+ saved = await readFile(paths.directory, paths.file);
116
+ } catch (error) {
117
+ throw new DeliveryCheckpointError(paths.file, error instanceof Error ? error.message : 'unreadable');
118
+ }
119
+ if (!saved) return null;
120
+ if (saved.seat !== seat) throw new DeliveryCheckpointError(paths.file, 'it belongs to another representative seat');
121
+ const { checkpoint, readThrough } = saved.state;
122
+ if (checkpoint && (readThrough === null || comparePoints(checkpoint, readThrough) > 0)) {
123
+ throw new DeliveryCheckpointError(paths.file, 'its checkpoint is beyond its read fence');
124
+ }
125
+ if (saved.state.returned.some((entry) => comparePoints(entry, checkpoint) <= 0 || comparePoints(entry, readThrough) > 0)) {
126
+ throw new DeliveryCheckpointError(paths.file, 'a returned entry lies outside its window');
127
+ }
128
+ return saved.state;
129
+ };
130
+ return {
131
+ /** Null when this binding generation has no checkpoint yet. A corrupt file fails closed. */
132
+ load,
133
+ /**
134
+ * Whether the seat's upgrade tombstone exists. Any object at that path,
135
+ * readable or not, counts, so a planted or damaged marker can only cause a
136
+ * replay (duplicates), never an import or a skip.
137
+ */
138
+ async migrated(): Promise<boolean> {
139
+ try {
140
+ if (!await validatePrivateDirectory(marker.directory, false)) return false;
141
+ await lstat(marker.file);
142
+ return true;
143
+ } catch (error) {
144
+ return (error as NodeJS.ErrnoException).code !== 'ENOENT';
145
+ }
146
+ },
147
+ /**
148
+ * Create the tombstone exclusively (O_EXCL, no-follow, 0600, fsynced).
149
+ * False when it already exists: another initializer got there first.
150
+ * `guard` runs just before the create.
151
+ */
152
+ async markMigrated(guard?: () => Promise<void>): Promise<boolean> {
153
+ await guard?.();
154
+ await validatePrivateDirectory(marker.directory, true);
155
+ let handle;
156
+ try {
157
+ handle = await open(marker.file, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
158
+ } catch (error) {
159
+ if ((error as NodeJS.ErrnoException).code === 'EEXIST') return false;
160
+ throw error;
161
+ }
162
+ try {
163
+ await handle.writeFile(JSON.stringify({ version: 1, seat }) + '\n');
164
+ await handle.sync();
165
+ } finally {
166
+ await handle.close();
167
+ }
168
+ return true;
169
+ },
170
+ /**
171
+ * Run a first-call initialization alone for this seat within the process:
172
+ * overlapping first reads see each other's result instead of both
173
+ * importing. Other processes are excluded by the tools lease, and the
174
+ * exclusive tombstone create backs that up.
175
+ */
176
+ initialize<T>(operation: () => Promise<T>): Promise<T> {
177
+ const key = `seat:${seat}`;
178
+ const result = (queues.get(key) ?? Promise.resolve()).then(operation, operation);
179
+ queues.set(key, result.catch(() => {}));
180
+ return result;
181
+ },
182
+ /**
183
+ * Whether any other generation of this seat already has a checkpoint, which
184
+ * means the one-time upgrade from the unread cursor already happened. An
185
+ * unreadable or unsafe sibling counts as one: the new generation then
186
+ * replays its history (duplicates, never loss) instead of trusting it.
187
+ */
188
+ async otherGenerationExists(): Promise<boolean> {
189
+ if (!await validatePrivateDirectory(deliveryRoot(), false)) return false;
190
+ const own = bindingFingerprint(binding);
191
+ for (const name of await readdir(deliveryRoot())) {
192
+ if (name === own || !/^[0-9a-f]{64}$/.test(name)) continue;
193
+ const directory = join(deliveryRoot(), name);
194
+ try {
195
+ if ((await readFile(directory, join(directory, 'checkpoint.json')))?.seat === seat) return true;
196
+ } catch {
197
+ return true;
198
+ }
199
+ }
200
+ return false;
201
+ },
202
+ /**
203
+ * Move either field forward only, from the state on disk at write time, in
204
+ * one atomic durable 0600 write. Always writes when no file exists yet, so a
205
+ * completed migration is never repeated. `guard` runs just before the write.
206
+ * Returns the states before and after, so callers report the real transition.
207
+ */
208
+ advance(update: Partial<DeliveryState>, guard?: () => Promise<void>): Promise<{ before: DeliveryState | null; after: DeliveryState }> {
209
+ const run = async () => {
210
+ const before = await load();
211
+ const checkpoint = later(before?.checkpoint ?? null, update.checkpoint ?? null);
212
+ const readThrough = later(before?.readThrough ?? null, update.readThrough ?? null);
213
+ // Union of returned windows, pruned to entries still after the checkpoint.
214
+ const returned = [...(before?.returned ?? []), ...(update.returned ?? [])]
215
+ .filter((entry, index, all) => all.findIndex((other) => other.id === entry.id) === index)
216
+ .filter((entry) => comparePoints(entry, checkpoint) > 0)
217
+ .sort((a, b) => comparePoints(a, b))
218
+ .slice(-RETURNED_CAP);
219
+ const after = { checkpoint, readThrough, returned };
220
+ // Invariant on every write: the delivered checkpoint never passes the read fence.
221
+ if (checkpoint && (readThrough === null || comparePoints(checkpoint, readThrough) > 0)) {
222
+ throw new DeliveryCheckpointError(paths.file, 'a write would move the checkpoint beyond the read fence');
223
+ }
224
+ if (before && JSON.stringify(before) === JSON.stringify(after)) return { before, after: before };
225
+ await guard?.();
226
+ await validatePrivateDirectory(paths.directory, true);
227
+ await atomicWrite0600(paths.file, JSON.stringify({ version: 1, seat, ...after }) + '\n', options);
228
+ return { before, after };
229
+ };
230
+ const result = (queues.get(paths.file) ?? Promise.resolve()).then(run, run);
231
+ queues.set(paths.file, result.catch(() => {}));
232
+ return result;
233
+ },
234
+ };
235
+ }
@@ -0,0 +1,115 @@
1
+ /** Bounded private listener tail and crash-recoverable hint metadata. */
2
+ import { createHash } from 'node:crypto';
3
+ import { join, relative, sep } from 'node:path';
4
+ import { borgConfigRoot, borgHomeRoot } from './private-root.js';
5
+ import { assertSecureRoot, atomicWrite0600, readStoreFile } from './seat-store.js';
6
+ import { formatInboxLine, inboxRawHasEntry, INBOX_TAIL_LINES_CAP, INBOX_TAIL_TRIM_THRESHOLD_LINES, type EnrichedEntry } from './log-stream.js';
7
+ import type { LocalServerCursor } from './local-server-cursor.js';
8
+ import type { RepresentativeBinding } from './representative-store.js';
9
+ import { isRepresentativeUuid } from './representative-store.js';
10
+
11
+ type Metadata = { visibility: 'direct' | 'broadcast'; documents: number };
12
+ type State = { version: 1; watermark: LocalServerCursor | null; resumeReset: boolean; hints: Record<string, Metadata> };
13
+ export interface ListenerHint {
14
+ event: 'entry'; entry_id: string; created_at: string; from_label: string; from_role: string;
15
+ visibility: 'direct' | 'broadcast' | null; request_id: string | null; documents: number | null; replay: boolean;
16
+ }
17
+ const empty = (): State => ({ version: 1, watermark: null, resumeReset: false, hints: {} });
18
+ export function listenerPaths(binding: RepresentativeBinding) {
19
+ const key = createHash('sha256').update(JSON.stringify([binding.worktree, binding.origin, binding.trustIdentity,
20
+ binding.cubeId, binding.representativeDroneId, binding.coordinatorDroneId, binding.boundAt])).digest('hex');
21
+ const directory = join(borgConfigRoot(), 'representative-inboxes', key);
22
+ return { directory, inbox: join(directory, 'inbox.log'), state: join(directory, 'stream.json') };
23
+ }
24
+ function requestId(message: string): string | null {
25
+ return /\brequest_id:\s*([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?![0-9a-f-])/i.exec(message)?.[1] ?? null;
26
+ }
27
+ function parseLine(line: string) {
28
+ const match = /^(\S+) (.*?) \((.*?)\): \[entry_id: ([0-9a-f-]+)\] (.*)$/i.exec(line);
29
+ if (!match || !isRepresentativeUuid(match[4]) || !Number.isFinite(Date.parse(match[1]))) throw new Error('Invalid representative inbox line');
30
+ return { id: match[4], created_at: match[1], label: match[2], role: match[3], message: match[5] };
31
+ }
32
+ function newer(a: LocalServerCursor, b: LocalServerCursor | null) {
33
+ return b === null || a.created_at > b.created_at || (a.created_at === b.created_at && a.id > b.id);
34
+ }
35
+
36
+ /** Validate a private directory under the Borg config root and every ancestor
37
+ * with the store's policy; never repairs unsafe state. False when absent. */
38
+ export async function validatePrivateDirectory(directory: string, create: boolean): Promise<boolean> {
39
+ let current = borgHomeRoot();
40
+ for (const component of relative(current, directory).split(sep)) {
41
+ if (!await assertSecureRoot(current, current === borgConfigRoot() || current.startsWith(borgConfigRoot() + sep) ? 'private' : 'owner-controlled', create)) return false;
42
+ current = join(current, component);
43
+ }
44
+ return assertSecureRoot(current, 'private', create);
45
+ }
46
+
47
+ export function createListenerInbox(binding: RepresentativeBinding, guard: () => Promise<void> = async () => {}) {
48
+ const paths = listenerPaths(binding);
49
+ const options = { secureRoot: paths.directory, verifyLeafIdentity: true, createRoot: false };
50
+ const validate = (create: boolean) => validatePrivateDirectory(paths.directory, create);
51
+ const load = async () => {
52
+ if (!await validate(false)) return { state: empty(), lines: [] as string[] };
53
+ const raw = await readStoreFile(paths.inbox, options) ?? '';
54
+ const saved = await readStoreFile(paths.state, options);
55
+ let state = empty();
56
+ if (saved !== null) {
57
+ // Lost/corrupt metadata cannot suppress surviving inbox lines on replay.
58
+ // The secure read itself still fails closed on ownership/path/identity drift.
59
+ try {
60
+ const parsed = JSON.parse(saved);
61
+ if (parsed.version === 1 && typeof parsed.resumeReset === 'boolean' && parsed.hints && typeof parsed.hints === 'object' &&
62
+ (parsed.watermark === null || (isRepresentativeUuid(parsed.watermark?.id) && Number.isFinite(Date.parse(parsed.watermark?.created_at))))) state = parsed;
63
+ } catch { /* recover the watermark and nullable replay metadata from the tail */ }
64
+ }
65
+ const lines = raw.split('\n').filter(Boolean);
66
+ for (const line of lines) {
67
+ const point = parseLine(line);
68
+ if (newer(point, state.watermark)) { state.watermark = { id: point.id, created_at: point.created_at }; state.resumeReset = false; }
69
+ }
70
+ return { state, lines };
71
+ };
72
+ const write = async (file: string, raw: string) => {
73
+ await guard(); await validate(true);
74
+ await atomicWrite0600(file, raw, options);
75
+ };
76
+ const hint = (line: string, state: State, replay: boolean): ListenerHint => {
77
+ const parsed = parseLine(line), metadata = state.hints[parsed.id];
78
+ return { event: 'entry', entry_id: parsed.id, created_at: parsed.created_at,
79
+ from_label: parsed.label, from_role: parsed.role, request_id: requestId(parsed.message), replay,
80
+ visibility: metadata?.visibility === 'direct' || metadata?.visibility === 'broadcast' ? metadata.visibility : null,
81
+ documents: Number.isInteger(metadata?.documents) && metadata.documents >= 0 ? metadata.documents : null };
82
+ };
83
+ return {
84
+ paths,
85
+ snapshot: async () => { const { state } = await load(); return { watermark: state.watermark?.id ?? null, inbox: paths.inbox }; },
86
+ dedupeCursor: async () => (await load()).state.watermark,
87
+ cursor: async () => { const { state } = await load(); return state.resumeReset ? null : state.watermark; },
88
+ clearCursor: async () => { const { state } = await load(); state.resumeReset = true; await write(paths.state, JSON.stringify(state)); },
89
+ replay: async (after: string) => {
90
+ const { state, lines } = await load(); const index = lines.findIndex(line => parseLine(line).id === after);
91
+ return { missing: index < 0, hints: lines.slice(index + 1).map(line => hint(line, state, true)) };
92
+ },
93
+ append: async (entry: EnrichedEntry & { id: string; created_at: string; visibility: 'direct' | 'broadcast' }, catchupCursor: LocalServerCursor | null = null) => {
94
+ if (!isRepresentativeUuid(entry.id) || !Number.isFinite(Date.parse(entry.created_at)) ||
95
+ !['direct', 'broadcast'].includes(entry.visibility)) throw new Error('Invalid representative stream entry');
96
+ const { state, lines } = await load(); const line = formatInboxLine(entry);
97
+ if (lines.some(value => parseLine(value).id === entry.id && inboxRawHasEntry(value, entry.id, line)) ||
98
+ (catchupCursor !== null && !newer(entry, catchupCursor))) return null;
99
+ state.hints[entry.id] = { visibility: entry.visibility, documents: entry.documents?.length ?? 0 };
100
+ // Metadata first, then atomic inbox publication, then watermark/metadata trim.
101
+ // A crash after publication recovers its watermark from the surviving tail.
102
+ await write(paths.state, JSON.stringify(state));
103
+ lines.push(line);
104
+ const kept = lines.length > INBOX_TAIL_TRIM_THRESHOLD_LINES ? lines.slice(-INBOX_TAIL_LINES_CAP) : lines;
105
+ await write(paths.inbox, kept.join('\n') + '\n');
106
+ if (newer(entry, state.watermark)) state.watermark = { id: entry.id, created_at: entry.created_at };
107
+ state.resumeReset = false;
108
+ state.hints = Object.fromEntries(kept.map(value => parseLine(value).id).filter(id => state.hints[id]).map(id => [id, state.hints[id]]));
109
+ await write(paths.state, JSON.stringify(state));
110
+ // Linear scans/rewrites are deliberately bounded to 1025 lines; a larger
111
+ // retention policy would need an indexed journal instead of this tail.
112
+ return hint(line, state, false);
113
+ },
114
+ };
115
+ }
@@ -0,0 +1,215 @@
1
+ /** Supervised body-free wake channel; independent of the lazy MCP tools lease. */
2
+ import { createHash } from 'node:crypto';
3
+ import { join } from 'node:path';
4
+ import { realpathSync } from 'node:fs';
5
+ import { borgConfigRoot } from './private-root.js';
6
+ import { acquireStreamLease, readOwnershipSnapshot, STREAM_OWNER_STALE_MS, type StreamLease } from './stream-owner.js';
7
+ import { representativeOwnerDeps } from './representative-owner.js';
8
+ import { createListenerInbox } from './representative-listener-store.js';
9
+ import { streamOnce, streamReconnectDelay, type StreamDeps } from './log-stream.js';
10
+ import { resolveRepresentativeContext, type RepresentativeCmdDeps } from './representative-cmd.js';
11
+ import { DroneEvictedError, CubeDeletedError } from './drone-lifecycle.js';
12
+ import { BorgServerTrustError, BorgServerUnreachableError } from './server-errors.js';
13
+ import { isTransportFailure } from './seat-probe.js';
14
+ import { readBorgServerTrustIdentity } from './server-trust.js';
15
+ import { RepresentativeError, verifyLiveBinding } from './representative-core.js';
16
+ import { bindingFingerprint, type RepresentativeBinding } from './representative-store.js';
17
+ import type { ActiveCube } from './cubes.js';
18
+
19
+ type StopReason = 'signal' | 'evicted' | 'rebound' | 'revoked' | 'trust-changed' | 'lease-lost' | 'fatal';
20
+ export interface ListenerOptions {
21
+ /** Controlled transport/timing seams; the CLI supplies no overrides. */
22
+ streamDeps?: StreamDeps;
23
+ heartbeatIntervalMs?: number;
24
+ reconnectDelay?: (attempt: number) => number;
25
+ }
26
+ function listenerOwnerDeps(binding: RepresentativeBinding) {
27
+ const authority = createHash('sha256').update(JSON.stringify([binding.origin, binding.trustIdentity])).digest('hex');
28
+ return { ...representativeOwnerDeps(binding), locksDir: join(borgConfigRoot(), 'representative-listener-locks', authority) };
29
+ }
30
+ export async function representativeListenerStatus(binding: RepresentativeBinding) {
31
+ const ownership = await readOwnershipSnapshot(binding.cubeId, binding.representativeDroneId, listenerOwnerDeps(binding));
32
+ let alive = false;
33
+ if (ownership.pid) {
34
+ try { process.kill(ownership.pid, 0); alive = true; }
35
+ catch (error) { alive = (error as NodeJS.ErrnoException).code === 'EPERM'; }
36
+ }
37
+ return { ...ownership, running: alive && (ownership.ageMs ?? Infinity) <= STREAM_OWNER_STALE_MS,
38
+ ...await createListenerInbox(binding).snapshot() };
39
+ }
40
+ function codeOf(error: unknown): string {
41
+ if (error instanceof DroneEvictedError) return 'DRONE_EVICTED';
42
+ if (error instanceof CubeDeletedError) return 'CUBE_DELETED';
43
+ if (error instanceof BorgServerTrustError) return 'TRUST_CHANGED';
44
+ return typeof (error as any)?.code === 'string' ? (error as any).code : 'LISTENER_ERROR';
45
+ }
46
+ function terminalReason(error: unknown): StopReason | undefined {
47
+ const code = codeOf(error);
48
+ if (code === 'DRONE_EVICTED' || code === 'CUBE_DELETED' || code === 'SEAT_UNAVAILABLE') return 'evicted';
49
+ if (code === 'BINDING_MISMATCH' || code === 'COORDINATOR_UNAVAILABLE') return 'rebound';
50
+ if (code.includes('TRUST')) return 'trust-changed';
51
+ if (['SESSION_REVOKED', 'SESSION_REJECTED', 'CREDENTIAL_REJECTED'].includes(code)) return 'revoked';
52
+ return undefined;
53
+ }
54
+
55
+ export async function runListener(
56
+ command: { worktree?: string; replayAfter?: string }, deps: RepresentativeCmdDeps, options: ListenerOptions = {},
57
+ ): Promise<number> {
58
+ const emit = async (event: unknown) => {
59
+ deps.stdout(JSON.stringify(event) + '\n');
60
+ // The command exits after its final event; wait for the pipe to flush.
61
+ await new Promise<void>((resolve, reject) => process.stdout.write('', error => error ? reject(error) : resolve()));
62
+ };
63
+ let lease: StreamLease | null = null;
64
+ let timer: ReturnType<typeof setInterval> | undefined;
65
+ let started = false, reason: StopReason | undefined, active: ActiveCube;
66
+ const abort = new AbortController();
67
+ let pending: Promise<unknown> = Promise.resolve();
68
+ const serial = <T>(fn: () => Promise<T>) => {
69
+ const result = pending.then(fn); pending = result.catch(() => {}); return result;
70
+ };
71
+ const stop = (why: StopReason) => { reason ??= why; abort.abort(); };
72
+ const signal = () => stop('signal');
73
+ let outputBroken = false;
74
+ const outputError = () => { outputBroken = true; reason = 'fatal'; abort.abort(); };
75
+ process.stdout.on('error', outputError);
76
+ const exitCode = () => reason === 'signal' ? 0 : reason === 'fatal' ? 1 : 4;
77
+ try {
78
+ let worktree = command.worktree ?? deps.cwd();
79
+ try { worktree = realpathSync(worktree); } catch { /* resolve refuses missing bindings */ }
80
+ worktree = deps.findProjectRoot(worktree);
81
+ const ctx = await resolveRepresentativeContext(worktree, deps);
82
+ // Only this server step maps transport failures to SERVER_UNREACHABLE; typed
83
+ // rejections keep their binding codes and storage keeps STORAGE_REFUSED.
84
+ await verifyLiveBinding(ctx).catch((error: unknown) => {
85
+ throw isTransportFailure(error) && !(error instanceof BorgServerUnreachableError)
86
+ ? new BorgServerUnreachableError('Borg server unreachable during startup verification', { cause: error }) : error;
87
+ });
88
+ const binding = ctx.binding;
89
+ active = (await deps.hydrateSeat(worktree))!;
90
+ const ownerDeps = listenerOwnerDeps(binding);
91
+ lease = await acquireStreamLease(binding.cubeId, binding.representativeDroneId, STREAM_OWNER_STALE_MS, ownerDeps);
92
+ if (!lease) {
93
+ const owner = await readOwnershipSnapshot(binding.cubeId, binding.representativeDroneId, ownerDeps);
94
+ await emit({ event: 'refused', code: 'REPRESENTATIVE_LISTENER_OWNED', exit_code: 3,
95
+ owner_pid: owner.pid ?? null, owner_started_at: owner.startedAt ?? null });
96
+ return 3;
97
+ }
98
+ const guard = async () => {
99
+ if (abort.signal.aborted) throw new Error('listener stopped');
100
+ const current = await deps.store.getBinding(worktree);
101
+ if (!current || JSON.stringify([current.origin, current.trustIdentity, current.cubeId, current.representativeDroneId, current.coordinatorDroneId, current.boundAt]) !==
102
+ JSON.stringify([binding.origin, binding.trustIdentity, binding.cubeId, binding.representativeDroneId, binding.coordinatorDroneId, binding.boundAt])) {
103
+ stop('rebound'); throw new RepresentativeError('BINDING_MISMATCH', 'Representative binding changed');
104
+ }
105
+ const saved = await deps.hydrateSeat(worktree);
106
+ if (!saved) { stop('revoked'); throw new RepresentativeError('SEAT_UNAVAILABLE', 'Representative seat unavailable'); }
107
+ if (saved.serverTrustIdentity !== binding.trustIdentity) { stop('trust-changed'); throw new BorgServerTrustError('Representative trust changed'); }
108
+ if (saved.cubeId !== binding.cubeId || saved.droneId !== binding.representativeDroneId || saved.apiUrl !== binding.origin) {
109
+ stop('rebound'); throw new RepresentativeError('BINDING_MISMATCH', 'Representative seat changed');
110
+ }
111
+ // Recheck authority trust while the stream stays open, not only on reconnect.
112
+ // Controlled transports may omit trust loading; production never does.
113
+ if (!options.streamDeps?.fetchImpl || options.streamDeps.loadTrust) {
114
+ try {
115
+ // Read fresh: the loader's local-authority cache never observes a change.
116
+ const identity = options.streamDeps?.loadTrust
117
+ ? (await options.streamDeps.loadTrust(binding.origin)).identity
118
+ : await readBorgServerTrustIdentity(binding.origin);
119
+ if (identity !== binding.trustIdentity) throw new BorgServerTrustError('Representative authority trust changed');
120
+ } catch (error) { stop('trust-changed'); throw error; }
121
+ }
122
+ const observed = await readOwnershipSnapshot(binding.cubeId, binding.representativeDroneId, ownerDeps);
123
+ if (observed.processNonce !== lease!.record.processNonce) { stop('lease-lost'); throw new Error('Listener ownership lost'); }
124
+ };
125
+ const inbox = createListenerInbox(binding, guard);
126
+ await inbox.snapshot(); // refuse unsafe persisted paths before emitting listening
127
+ process.once('SIGTERM', signal); process.once('SIGINT', signal);
128
+ timer = setInterval(() => {
129
+ void serial(async () => { await guard(); if (!await lease!.refresh()) stop('lease-lost'); }).catch(error => { if (!reason) stop(terminalReason(error) ?? 'lease-lost'); });
130
+ }, options.heartbeatIntervalMs ?? 20_000);
131
+ let attempt = 0;
132
+ let consumerFailure: unknown;
133
+ let pendingGap: string | null | undefined;
134
+ // Failures of the listener's own guard, storage and output are fatal; every
135
+ // other non-terminal stream failure is transport and reconnects, as in the
136
+ // ordinary stream loop (pinned HTTPS surfaces raw errno/string codes).
137
+ const local = <A extends unknown[], T>(fn: (...args: A) => Promise<T>) => async (...args: A): Promise<T> => {
138
+ try { return await fn(...args); } catch (error) { consumerFailure ??= error; throw error; }
139
+ };
140
+ while (!reason) {
141
+ const resumed = await inbox.cursor();
142
+ try {
143
+ consumerFailure = undefined;
144
+ await local(() => serial(guard))();
145
+ await streamOnce(active, resumed?.id ?? null, () => {}, {
146
+ ...options.streamDeps, getCursor: local(async () => inbox.cursor()), abortSignal: abort.signal,
147
+ consumer: {
148
+ catchupCursor: await local(() => inbox.dedupeCursor())(),
149
+ beforeEvent: local(() => serial(guard)),
150
+ clearCursor: local(() => serial(async () => {
151
+ const previous = await inbox.snapshot(); await inbox.clearCursor();
152
+ if (started) await emit({ event: 'gap', after: previous.watermark, reason: 'cursor-expired' });
153
+ else pendingGap = previous.watermark;
154
+ })),
155
+ connected: local(() => serial(async () => {
156
+ await guard();
157
+ if (!started) {
158
+ await emit({ event: 'listening', cube_id: binding.cubeId, drone_id: binding.representativeDroneId,
159
+ binding_fingerprint: bindingFingerprint(binding), ...await inbox.snapshot() });
160
+ started = true;
161
+ if (pendingGap !== undefined) await emit({ event: 'gap', after: pendingGap, reason: 'cursor-expired' });
162
+ if (command.replayAfter) {
163
+ const replay = await inbox.replay(command.replayAfter);
164
+ if (replay.missing) await emit({ event: 'gap', after: command.replayAfter, reason: 'replay-checkpoint-missing' });
165
+ for (const hint of replay.hints) { await guard(); await emit(hint); }
166
+ }
167
+ } else await emit({ event: 'connected', resumed_from: resumed?.id ?? null });
168
+ })),
169
+ log: local((event, catchupCursor) => serial(async () => {
170
+ await guard();
171
+ const hint = await inbox.append({ ...event.data, id: event.id }, catchupCursor);
172
+ if (hint) { await guard(); await emit(hint); }
173
+ })),
174
+ },
175
+ });
176
+ attempt = 0;
177
+ } catch (error) {
178
+ if (reason) { if (!started) throw error; break; }
179
+ if (consumerFailure) throw consumerFailure;
180
+ const terminal = terminalReason(error);
181
+ if (terminal) { if (!started) throw error; stop(terminal); break; }
182
+ deps.stderr(`Representative listener: ${error instanceof Error ? error.message : 'stream disconnected'}\n`);
183
+ }
184
+ if (reason) break;
185
+ const delay = Math.round((options.reconnectDelay ?? streamReconnectDelay)(attempt++));
186
+ if (started) await emit({ event: 'reconnecting', attempt, delay_ms: delay });
187
+ await new Promise<void>(resolve => {
188
+ const done = () => { clearTimeout(timeout); abort.signal.removeEventListener('abort', done); resolve(); };
189
+ const timeout = setTimeout(done, delay); abort.signal.addEventListener('abort', done, { once: true });
190
+ if (abort.signal.aborted) done();
191
+ });
192
+ }
193
+ return exitCode();
194
+ } catch (error) {
195
+ deps.stderr(`Representative listener refused: ${error instanceof Error ? error.message : String(error)}\n`);
196
+ if (!started && (error instanceof RepresentativeError || terminalReason(error))) {
197
+ await emit({ event: 'refused', code: typeof (error as any)?.code === 'string' ? (error as any).code : 'BACKEND_ERROR', exit_code: 2 }); return 2;
198
+ }
199
+ if (started) reason = 'fatal';
200
+ else if (!outputBroken) await emit({ event: 'refused', code: error instanceof BorgServerUnreachableError
201
+ ? 'REPRESENTATIVE_LISTENER_SERVER_UNREACHABLE' : 'REPRESENTATIVE_LISTENER_STORAGE_REFUSED', exit_code: 1 });
202
+ return 1;
203
+ } finally {
204
+ if (timer) clearInterval(timer);
205
+ process.removeListener('SIGTERM', signal); process.removeListener('SIGINT', signal);
206
+ await pending;
207
+ let releaseFailed = false;
208
+ try { await lease?.release(); } catch (error) { releaseFailed = true; reason = 'fatal'; deps.stderr(`Listener lease release failed: ${String(error)}\n`); }
209
+ if (started && reason && !outputBroken) {
210
+ try { await emit({ event: 'stopped', reason, exit_code: exitCode() }); } catch { /* stdout failure is fatal to the host */ }
211
+ }
212
+ process.stdout.removeListener('error', outputError);
213
+ if (releaseFailed || outputBroken) return 1;
214
+ }
215
+ }