@matuto-ai/claude-code-broker-connector 0.0.0-stage → 0.2.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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Bruno Orsolon
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,11 @@
1
+ # Bundled Psst runtime
2
+
3
+ Unmodified runtime source from https://github.com/matuto-ai/psst at commit
4
+ ba2219cd87a1feac23e01b23d2c2b3d3bdd65359. Copyright 2026 Bruno Orsolon, MIT.
5
+ See LICENSE. src/reference-pin.mjs verifies all seven runtime source hashes
6
+ before dynamic loading. No broker checkout, npm dependency, postinstall hook,
7
+ or network fetch is needed by the installed connector.
8
+
9
+ The adapter-specific cooperative-inbox.mjs and cooperative-stdio.mjs files are
10
+ outside this vendor snapshot. They add an explicitly acknowledged application
11
+ read queue without claiming a native injection/consumption receipt.
@@ -0,0 +1,318 @@
1
+ import { request as httpRequest } from 'node:http';
2
+ import { request as httpsRequest } from 'node:https';
3
+ import { readFileSync, openSync, fstatSync, closeSync, constants } from 'node:fs';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { setTimeout as delay } from 'node:timers/promises';
6
+ import { ApiError, canonical, validateMetadata, validateRegistration, validateMetadataPatch,
7
+ validateClaim, validateCompletion, validateRenew, validateUuid } from '../src/protocol.mjs';
8
+ import { LocalError } from './errors.mjs';
9
+
10
+ const ALLOWED_ERROR_CODES = new Set(['invalid_request', 'unauthenticated', 'forbidden', 'recipient_not_found',
11
+ 'message_not_found', 'identity_conflict', 'stale_lease_epoch', 'idempotency_conflict', 'metadata_conflict',
12
+ 'claim_conflict', 'session_closed', 'message_expired', 'payload_too_large',
13
+ 'incompatible_receiver_transport', 'receiving_disabled', 'rate_limited', 'mailbox_full',
14
+ 'storage_unavailable', 'not_ready']);
15
+
16
+ export class BrokerError extends LocalError {
17
+ constructor(status, value, retryAfter = 0) {
18
+ super('invalid_response', { effect: 'unknown' });
19
+ this.name = 'BrokerError';
20
+ this.status = status;
21
+ this.retryAfter = retryAfter;
22
+ const raw = value?.error;
23
+ const code = ALLOWED_ERROR_CODES.has(raw?.code) ? raw.code : 'invalid_request';
24
+ // Never reproduce a remote free-text message or details in local tools/logs.
25
+ this.api = new ApiError(status, code, null, {
26
+ effect: ['not_applied', 'unknown'].includes(raw?.effect) ? raw.effect : 'unknown',
27
+ retryable: raw?.retryable === true && [429, 503].includes(status),
28
+ });
29
+ this.code = this.api.code;
30
+ this.message = this.api.message;
31
+ this.effect = this.api.effect;
32
+ this.retryable = this.api.retryable;
33
+ }
34
+ toJSON() { return this.api.toJSON(null); }
35
+ }
36
+
37
+ function readSecret(path) {
38
+ let fd;
39
+ try {
40
+ fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
41
+ const stat = fstatSync(fd);
42
+ if (!stat.isFile() || (stat.mode & 0o077) !== 0 || stat.size > 8192
43
+ || (process.getuid && stat.uid !== process.getuid())) throw new Error('invalid');
44
+ const token = readFileSync(fd, 'utf8').trim();
45
+ if (!/^[A-Za-z0-9_-]{43,256}$/.test(token)) throw new Error('invalid');
46
+ return token;
47
+ } catch { throw new LocalError('invalid_configuration', { field: 'tokenFile' }); }
48
+ finally { if (fd !== undefined) closeSync(fd); }
49
+ }
50
+
51
+ function positiveSeconds(value, fallback) {
52
+ return Number.isSafeInteger(value) && value > 0 && value <= 365 * 86400 ? value : fallback;
53
+ }
54
+
55
+ /** HTTPS-only by default. No redirects; no credential-bearing URLs or command-line arguments. */
56
+ export class BrokerClient {
57
+ constructor({ url, tokenFile, tokenEnvironment, caFile, insecureDevelopment = false, inbox,
58
+ now = Date.now, random = Math.random, sleep = delay, maxAttempts = 4,
59
+ maxResponseBytes = 256 * 1024, requestTimeoutMs = 40000 }) {
60
+ try { this.base = new URL(url); } catch { throw new LocalError('invalid_configuration', { field: 'url' }); }
61
+ const host = this.base.hostname.replace(/^\[|\]$/g, '');
62
+ if (this.base.username || this.base.password || this.base.search || this.base.hash || this.base.pathname !== '/'
63
+ || !['https:', 'http:'].includes(this.base.protocol)
64
+ || (this.base.protocol === 'http:' && (!insecureDevelopment || !['127.0.0.1', '::1'].includes(host)))) {
65
+ throw new LocalError('invalid_configuration', { field: 'url' });
66
+ }
67
+ if (!inbox || (!tokenFile && !tokenEnvironment) || (tokenFile && tokenEnvironment)
68
+ || !Number.isSafeInteger(maxAttempts) || maxAttempts < 1 || maxAttempts > 10
69
+ || !Number.isSafeInteger(maxResponseBytes) || maxResponseBytes < 1024 || maxResponseBytes > 1048576
70
+ || !Number.isSafeInteger(requestTimeoutMs) || requestTimeoutMs < 100 || requestTimeoutMs > 120000) throw new LocalError('invalid_configuration');
71
+ this.inbox = inbox;
72
+ this.now = now;
73
+ this.random = random;
74
+ this.sleep = sleep;
75
+ this.maxAttempts = maxAttempts;
76
+ this.maxResponseBytes = maxResponseBytes;
77
+ this.requestTimeoutMs = requestTimeoutMs;
78
+ this.tokenFile = tokenFile;
79
+ this.tokenEnvironment = tokenEnvironment;
80
+ try { this.ca = caFile ? readFileSync(caFile) : undefined; }
81
+ catch { throw new LocalError('invalid_configuration', { field: 'caFile' }); }
82
+ this.clockOffset = 0;
83
+ this.connectPromise = null;
84
+ }
85
+
86
+ get identity() { return this.inbox.identity; }
87
+ get attachment() { return this.inbox.getState('attachment') ?? {}; }
88
+ get policy() { return this.attachment.policy ?? {}; }
89
+ serverNow() { return this.now() + this.clockOffset; }
90
+
91
+ credential() {
92
+ if (this.tokenFile) return readSecret(this.tokenFile);
93
+ const token = process.env[this.tokenEnvironment];
94
+ if (typeof token !== 'string' || !/^[A-Za-z0-9_-]{43,256}$/.test(token)) throw new LocalError('invalid_configuration', { field: 'tokenEnvironment' });
95
+ return token;
96
+ }
97
+
98
+ async request(method, path, { body, key, session = true, signal, idempotent = false, deadline = Infinity } = {}) {
99
+ if (!/^\/v1\/[A-Za-z0-9_/?=&.%+~-]+$/.test(path)) throw new LocalError('invalid_configuration');
100
+ for (let attempt = 0; ; attempt++) {
101
+ if (this.now() >= deadline) throw new LocalError('retry_window_elapsed', { effect: 'unknown' });
102
+ try { return await this.requestOnce(method, path, { body, key, session, signal, timeoutMs: Math.min(this.requestTimeoutMs, deadline - this.now()) }); }
103
+ catch (error) {
104
+ if (signal?.aborted) throw new LocalError('request_aborted', { effect: 'unknown' });
105
+ const retryable = error instanceof BrokerError ? error.retryable
106
+ : error instanceof LocalError && error.code === 'request_failed';
107
+ if (!idempotent || !retryable || attempt + 1 >= this.maxAttempts) throw error;
108
+ const milliseconds = Math.max(error.retryAfter * 1000 || 0, Math.min(10000, 250 * 2 ** attempt) * (0.8 + this.random() * 0.4));
109
+ if (this.now() + milliseconds >= deadline) throw new LocalError('retry_window_elapsed', { effect: 'unknown' });
110
+ try { await this.sleep(milliseconds, undefined, { signal }); }
111
+ catch { throw new LocalError('request_aborted', { effect: 'unknown' }); }
112
+ }
113
+ }
114
+ }
115
+
116
+ requestOnce(method, path, { body, key, session, signal, timeoutMs }) {
117
+ const serialized = body === undefined ? null : canonical(body);
118
+ if (serialized && Buffer.byteLength(serialized) > 98304) throw new LocalError('invalid_configuration', { field: 'body' });
119
+ const headers = { Accept: 'application/json', Authorization: `Bearer ${this.credential()}` };
120
+ if (session) {
121
+ if (!Number.isSafeInteger(this.attachment.lease_epoch) || this.attachment.lease_epoch < 1) throw new LocalError('session_mismatch');
122
+ headers['X-Client-ID'] = this.identity.client_id;
123
+ headers['X-Lease-Epoch'] = String(this.attachment.lease_epoch);
124
+ }
125
+ if (key) headers['Idempotency-Key'] = key;
126
+ if (serialized !== null) { headers['Content-Type'] = 'application/json'; headers['Content-Length'] = Buffer.byteLength(serialized); }
127
+ const target = new URL(path, this.base);
128
+ return new Promise((resolve, reject) => {
129
+ let settled = false;
130
+ let absoluteTimer;
131
+ const finish = (error, value) => {
132
+ if (settled) return;
133
+ settled = true;
134
+ clearTimeout(absoluteTimer);
135
+ if (error) reject(error); else resolve(value);
136
+ };
137
+ const transport = target.protocol === 'https:' ? httpsRequest : httpRequest;
138
+ const request = transport(target, { method, headers, signal, ca: this.ca,
139
+ rejectUnauthorized: true, timeout: timeoutMs, maxHeaderSize: 16384 }, response => {
140
+ const chunks = [];
141
+ let bytes = 0;
142
+ response.on('data', chunk => {
143
+ bytes += chunk.length;
144
+ if (bytes > this.maxResponseBytes) { finish(new LocalError('invalid_response', { effect: 'unknown' })); response.destroy(); }
145
+ else chunks.push(chunk);
146
+ });
147
+ response.on('error', () => finish(new LocalError('request_failed', { effect: 'unknown', retryable: true })));
148
+ response.on('end', () => {
149
+ try {
150
+ const status = response.statusCode;
151
+ if (status === 204) { finish(null, null); return; }
152
+ if (!String(response.headers['content-type'] ?? '').toLowerCase().startsWith('application/json')) throw new LocalError('invalid_response', { effect: 'unknown' });
153
+ const value = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks)));
154
+ if (status < 200 || status >= 300) {
155
+ const retryAfter = /^\d+$/.test(String(response.headers['retry-after'] ?? '')) ? Math.min(3600, Number(response.headers['retry-after'])) : 0;
156
+ finish(new BrokerError(status, value, retryAfter)); return;
157
+ }
158
+ const timestamp = value?.server_time;
159
+ // Registration/send/completion retries may return their original
160
+ // stored result. Historical server_time is not a current clock sample.
161
+ if ((method === 'GET' || path.endsWith('/heartbeat') || method === 'PATCH')
162
+ && typeof timestamp === 'string' && Number.isFinite(Date.parse(timestamp))) this.clockOffset = Date.parse(timestamp) - this.now();
163
+ finish(null, value);
164
+ } catch (error) { finish(error instanceof LocalError ? error : new LocalError('invalid_response', { effect: 'unknown' })); }
165
+ });
166
+ });
167
+ request.on('timeout', () => request.destroy());
168
+ request.on('error', () => finish(new LocalError(signal?.aborted ? 'request_aborted' : 'request_failed', { effect: 'unknown', retryable: !signal?.aborted })));
169
+ absoluteTimer = setTimeout(() => request.destroy(), timeoutMs);
170
+ request.end(serialized);
171
+ });
172
+ }
173
+
174
+ capabilities(receiveEnabled = true) {
175
+ return { protocol_version: '1', adapter_version: '0.1.0', client_type: this.identity.client_type,
176
+ receive_enabled: receiveEnabled, durable_inbox: true };
177
+ }
178
+
179
+ applyRegistration(result, pending) {
180
+ if (!result || result.client_id !== this.identity.client_id || !Number.isSafeInteger(result.lease_epoch)
181
+ || result.lease_epoch !== pending.body.previous_epoch + 1 || !Number.isSafeInteger(result.metadata_revision)
182
+ || result.metadata_revision < 1 || !result.policy) throw new LocalError('invalid_response', { effect: 'unknown' });
183
+ this.inbox.setState('attachment', { ...this.attachment, ...result, connection_id: pending.body.connection_id });
184
+ this.inbox.setState('metadata', pending.body.metadata);
185
+ this.inbox.setState('registration_pending', null);
186
+ this.inbox.setState('claim_pending', null);
187
+ return result;
188
+ }
189
+
190
+ async register(metadata, { receiveEnabled = true, signal } = {}) {
191
+ let pending = this.inbox.getState('registration_pending');
192
+ if (!pending) {
193
+ const body = validateRegistration({ protocol_version: '1', ...this.identity, connection_id: randomUUID(),
194
+ previous_epoch: this.attachment.lease_epoch ?? 0, metadata: validateMetadata(metadata), capabilities: this.capabilities(receiveEnabled) });
195
+ pending = { body, retry_until: this.now() + positiveSeconds(this.policy.registration_retry_seconds, 86400) * 1000 };
196
+ this.inbox.setState('registration_pending', pending);
197
+ }
198
+ const result = await this.request('POST', '/v1/sessions', { body: pending.body, session: false,
199
+ idempotent: true, deadline: pending.retry_until, signal });
200
+ const state = await this.request('GET', `/v1/sessions/${this.identity.client_id}`, { session: false, idempotent: true, signal });
201
+ if (state?.client_id !== this.identity.client_id || state.session_instance_id !== this.identity.session_instance_id
202
+ || state.client_type !== this.identity.client_type || state.connection_id !== pending.body.connection_id
203
+ || state.lease_epoch !== result?.lease_epoch) throw new LocalError('session_mismatch', { effect: 'unknown' });
204
+ const confirmed = this.applyRegistration({ ...result, server_time: state.server_time, lease_expires_at: state.lease_expires_at }, pending);
205
+ if (state.closed === true) throw new BrokerError(410, { error: { code: 'session_closed', effect: 'unknown' } });
206
+ if (state.connected !== true || Date.parse(state.lease_expires_at) <= this.serverNow()) {
207
+ throw new BrokerError(409, { error: { code: 'stale_lease_epoch', effect: 'unknown' } });
208
+ }
209
+ return confirmed;
210
+ }
211
+
212
+ async reconnect({ verifySession, signal, metadata, receiveEnabled } = {}) {
213
+ if (this.connectPromise) return this.connectPromise;
214
+ this.connectPromise = this.reconnectOnce({ verifySession, signal, metadata, receiveEnabled });
215
+ try { return await this.connectPromise; } finally { this.connectPromise = null; }
216
+ }
217
+
218
+ async reconnectOnce({ verifySession, signal, metadata, receiveEnabled }) {
219
+ if (typeof verifySession !== 'function' || await verifySession(this.identity) !== true) throw new LocalError('session_mismatch');
220
+ const local = this.attachment;
221
+ const pending = this.inbox.getState('registration_pending');
222
+ const state = await this.request('GET', `/v1/sessions/${this.identity.client_id}`, { session: false, idempotent: true, signal });
223
+ if (state.client_id !== this.identity.client_id || state.session_instance_id !== this.identity.session_instance_id
224
+ || state.client_type !== this.identity.client_type || !Number.isSafeInteger(state.lease_epoch)
225
+ || ![local.connection_id, pending?.body.connection_id].filter(Boolean).includes(state.connection_id)) throw new LocalError('session_mismatch');
226
+ if (state.closed === true || state.closed_at != null) throw new BrokerError(410, { error: { code: 'session_closed', effect: 'not_applied' } });
227
+ // The owned-state read is a reconciliation, not permission to steal a different attachment.
228
+ this.inbox.setState('attachment', { ...local, lease_epoch: state.lease_epoch, connection_id: state.connection_id,
229
+ metadata_revision: state.metadata_revision, policy: state.policy ?? local.policy });
230
+ this.inbox.setState('registration_pending', null);
231
+ const refreshedMetadata = metadata ?? pending?.body.metadata ?? state.metadata ?? this.inbox.getState('metadata');
232
+ return this.register(refreshedMetadata, { receiveEnabled: receiveEnabled ?? pending?.body.capabilities.receive_enabled ?? state.capabilities?.receive_enabled ?? false, signal });
233
+ }
234
+
235
+ async heartbeat({ signal } = {}) {
236
+ return this.request('POST', `/v1/sessions/${this.identity.client_id}/heartbeat`, { body: {}, idempotent: true, signal });
237
+ }
238
+
239
+ async closeSession({ signal } = {}) {
240
+ return this.request('POST', `/v1/sessions/${this.identity.client_id}/close`, { body: {}, idempotent: true, signal });
241
+ }
242
+
243
+ async listClients(filters = {}, { signal } = {}) {
244
+ const allowed = ['connected', 'client_type', 'name', 'project', 'limit', 'cursor'];
245
+ if (Object.keys(filters).some(key => !allowed.includes(key))) throw new LocalError('invalid_configuration');
246
+ const query = new URLSearchParams();
247
+ for (const [key, value] of Object.entries(filters)) query.set(key, String(value));
248
+ return this.request('GET', `/v1/clients${query.size ? `?${query}` : ''}`, { idempotent: true, signal });
249
+ }
250
+
251
+ async updateMetadata(patch, { expectedRevision = this.attachment.metadata_revision, signal } = {}) {
252
+ const body = validateMetadataPatch({ metadata_patch: patch, expected_metadata_revision: expectedRevision });
253
+ // CAS is not blindly retried after response loss: read owned state to reconcile first.
254
+ const result = await this.request('PATCH', `/v1/sessions/${this.identity.client_id}/metadata`, { body, signal });
255
+ if (!Number.isSafeInteger(result?.metadata_revision)) throw new LocalError('invalid_response', { effect: 'unknown' });
256
+ this.inbox.setState('attachment', { ...this.attachment, metadata_revision: result.metadata_revision });
257
+ this.inbox.setState('metadata', { ...this.inbox.getState('metadata'), ...patch });
258
+ return result;
259
+ }
260
+
261
+ async refreshOwnedMetadata({ signal } = {}) {
262
+ const state = await this.request('GET', `/v1/sessions/${this.identity.client_id}`, { session: false, idempotent: true, signal });
263
+ if (state.client_id !== this.identity.client_id || state.session_instance_id !== this.identity.session_instance_id
264
+ || state.connection_id !== this.attachment.connection_id || state.lease_epoch !== this.attachment.lease_epoch
265
+ || !Number.isSafeInteger(state.metadata_revision)) throw new LocalError('session_mismatch');
266
+ const metadata = validateMetadata(state.metadata);
267
+ this.inbox.setState('attachment', { ...this.attachment, metadata_revision: state.metadata_revision });
268
+ this.inbox.setState('metadata', metadata);
269
+ return metadata;
270
+ }
271
+
272
+ prepareSend(request, options = {}) {
273
+ return this.inbox.prepareSend(request, { retryWindowSeconds: positiveSeconds(this.policy.send_retry_seconds, 86400), ...options });
274
+ }
275
+
276
+ async sendPrepared(key, { signal } = {}) {
277
+ const operation = this.inbox.outgoing(key);
278
+ if (!operation) throw new LocalError('invalid_configuration');
279
+ if (operation.result) return { ...operation.result, send_key: key };
280
+ const result = await this.request('POST', '/v1/messages', { body: operation.request, key, idempotent: true,
281
+ deadline: operation.retry_until, signal });
282
+ try { validateUuid(result?.message_id); } catch { throw new LocalError('invalid_response', { effect: 'unknown' }); }
283
+ this.inbox.recordSendResult(key, result);
284
+ if (operation.request.metadata_patch) {
285
+ this.inbox.setState('metadata', { ...this.inbox.getState('metadata'), ...operation.request.metadata_patch });
286
+ // Sender revision is not guessed; reconcile from owned state before a future CAS mutation.
287
+ }
288
+ return { ...result, send_key: key };
289
+ }
290
+
291
+ async messageStatus(messageId, { signal } = {}) {
292
+ return this.request('GET', `/v1/messages/${validateUuid(messageId)}`, { idempotent: true, signal });
293
+ }
294
+
295
+ async claim(waitSeconds = 30, { signal } = {}) {
296
+ let pending = this.inbox.getState('claim_pending');
297
+ if (!pending) {
298
+ pending = { key: randomUUID(), body: validateClaim({ wait_seconds: waitSeconds }), lease_epoch: this.attachment.lease_epoch };
299
+ this.inbox.setState('claim_pending', pending);
300
+ }
301
+ if (pending.lease_epoch !== this.attachment.lease_epoch) throw new LocalError('session_mismatch');
302
+ const result = await this.request('POST', '/v1/mailbox/claim', { body: pending.body, key: pending.key, idempotent: true, signal });
303
+ if (result === null) this.inbox.setState('claim_pending', null);
304
+ return result;
305
+ }
306
+
307
+ async complete(messageId, completion, { signal } = {}) {
308
+ const result = await this.request('POST', `/v1/mailbox/${validateUuid(messageId)}/complete`, {
309
+ body: validateCompletion(completion), idempotent: true, signal });
310
+ this.inbox.setState('claim_pending', null);
311
+ return result;
312
+ }
313
+
314
+ async renew(messageId, claimId, { signal } = {}) {
315
+ return this.request('POST', `/v1/mailbox/${validateUuid(messageId)}/renew`, {
316
+ body: validateRenew({ claim_id: claimId }), idempotent: true, signal });
317
+ }
318
+ }
@@ -0,0 +1,42 @@
1
+ /** Local errors deliberately never include content, paths, credentials or native responses. */
2
+ const MESSAGES = {
3
+ invalid_configuration: 'Correct the local adapter configuration before retrying.',
4
+ session_mismatch: 'Verify the exact surviving harness session; do not adopt its mailbox.',
5
+ owner_conflict: 'Another worker owns this session inbox. Stop it before starting another.',
6
+ storage_unavailable: 'Restore local durable storage before retrying.',
7
+ inbox_full: 'The local inbox or deduplication budget is full; preserve pending input.',
8
+ envelope_mismatch: 'A message ID changed its immutable envelope. Pause and inspect the protocol.',
9
+ envelope_expired: 'The transport deadline passed; do not accept a new inbox item.',
10
+ invalid_envelope: 'The broker envelope is invalid; no inbox item was accepted.',
11
+ invalid_response: 'The broker response is invalid. Reconcile the original operation.',
12
+ request_failed: 'The network result is unknown. Retry only the original idempotent operation.',
13
+ request_aborted: 'The request was canceled. Reconcile any potentially applied operation.',
14
+ retry_window_elapsed: 'The automatic retry window ended. Reconcile; do not allocate a replacement key.',
15
+ send_conflict: 'The local send key already names a different request.',
16
+ injection_uncertain: 'Native acceptance is uncertain. The local FIFO head is paused for operator review.',
17
+ unsafe_boundary: 'Keep the input pending until the harness provides a verified safe boundary.',
18
+ receipt_required: 'A supported native acceptance receipt is required before consumption is recorded.',
19
+ unknown_tool: 'Use a documented broker tool name.',
20
+ };
21
+
22
+ export class LocalError extends Error {
23
+ constructor(code, { effect = 'not_applied', retryable = false, field = null } = {}) {
24
+ super(MESSAGES[code] ?? MESSAGES.invalid_configuration);
25
+ this.name = 'LocalError';
26
+ this.code = code;
27
+ this.effect = effect;
28
+ this.retryable = retryable;
29
+ this.field = field;
30
+ }
31
+ toJSON() {
32
+ return { error: { code: this.code, message: this.message, action: this.message,
33
+ retryable: this.retryable, effect: this.effect, field: this.field,
34
+ contract_ref: 'docs/reference.md#local-errors' } };
35
+ }
36
+ }
37
+
38
+ export function safeError(error) {
39
+ if (error instanceof LocalError) return error.toJSON();
40
+ if (error?.toJSON && error?.name === 'BrokerError') return error.toJSON();
41
+ return new LocalError('storage_unavailable', { effect: 'unknown' }).toJSON();
42
+ }