@matuto-ai/opencode-broker-connector 0.3.0 → 0.4.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 (42) hide show
  1. package/README.md +34 -12
  2. package/bin/doctor.mjs +5 -0
  3. package/docs/candidate-verification.md +25 -0
  4. package/docs/integration.md +8 -6
  5. package/docs/message-lifecycle.md +24 -0
  6. package/docs/native-automation.md +62 -2
  7. package/docs/native-v2.md +121 -0
  8. package/docs/owned-run.md +4 -2
  9. package/docs/publishing.md +2 -2
  10. package/docs/reference-snapshot.sha256 +5 -5
  11. package/docs/sandbox-persistence.md +9 -0
  12. package/docs/verification.md +3 -1
  13. package/package.json +9 -4
  14. package/plugin/tui.mjs +3 -0
  15. package/plugin/v2.mjs +18 -0
  16. package/src/capabilities.mjs +13 -0
  17. package/src/cooperative-inbox.mjs +13 -8
  18. package/src/installation.mjs +2 -1
  19. package/src/message-format.mjs +26 -0
  20. package/src/native-plugin.mjs +8 -6
  21. package/src/opencode-adapter.mjs +31 -15
  22. package/src/reference-pin.mjs +6 -6
  23. package/src/reference.mjs +1 -1
  24. package/src/setup-ui.mjs +1 -1
  25. package/src/tool-catalog.mjs +7 -2
  26. package/src/tool-scope.mjs +26 -0
  27. package/src/v2-delivery.mjs +149 -0
  28. package/src/v2-inbox.mjs +33 -0
  29. package/src/v2-lock.mjs +17 -0
  30. package/src/v2-plugin.mjs +192 -0
  31. package/src/v2-setup.mjs +133 -0
  32. package/v2/LICENSE +21 -0
  33. package/v2/README.md +17 -0
  34. package/v2/index.mjs +2 -0
  35. package/v2/package-lock.json +420 -0
  36. package/v2/package.json +13 -0
  37. package/vendor/broker/ORIGIN.md +11 -4
  38. package/vendor/broker/reference/client.mjs +90 -15
  39. package/vendor/broker/reference/inbox.mjs +55 -10
  40. package/vendor/broker/reference/mcp-stdio.mjs +5 -1
  41. package/vendor/broker/reference/tools.mjs +38 -14
  42. package/vendor/broker/src/protocol.mjs +19 -0
@@ -2,7 +2,7 @@ import { DatabaseSync } from 'node:sqlite';
2
2
  import { mkdirSync, lstatSync, statfsSync, readdirSync, chmodSync, realpathSync } from 'node:fs';
3
3
  import { resolve, join } from 'node:path';
4
4
  import { randomUUID } from 'node:crypto';
5
- import { canonical, digest, validateEnvelope, validateUuid, validateSend, validateIdempotencyKey, TYPES } from '../src/protocol.mjs';
5
+ import { canonical, digest, validateEnvelope, validateUuid, validateSend, validateIdempotencyKey, validateResend, TYPES } from '../src/protocol.mjs';
6
6
  import { LocalError } from './errors.mjs';
7
7
 
8
8
  export function newSessionIdentity(clientType) {
@@ -68,6 +68,12 @@ export class Inbox {
68
68
  key TEXT PRIMARY KEY, digest TEXT NOT NULL, request TEXT NOT NULL,
69
69
  created_at INTEGER NOT NULL, retry_until INTEGER NOT NULL, result TEXT);
70
70
  PRAGMA user_version=1;`);
71
+ if(!this.db.prepare('PRAGMA table_info(outgoing)').all().some(c=>c.name==='kind'))this.db.exec("ALTER TABLE outgoing ADD COLUMN kind TEXT NOT NULL DEFAULT 'send'");
72
+ for(const [name,type] of [['failure','TEXT'],['uncertain','INTEGER NOT NULL DEFAULT 0'],['operation_id','TEXT']]){
73
+ if(!this.db.prepare('PRAGMA table_info(outgoing)').all().some(c=>c.name===name))this.db.exec(`ALTER TABLE outgoing ADD COLUMN ${name} ${type}`);
74
+ }
75
+ this.db.exec('CREATE UNIQUE INDEX IF NOT EXISTS outgoing_operation ON outgoing(operation_id) WHERE operation_id IS NOT NULL');
76
+ this.db.exec('CREATE INDEX IF NOT EXISTS outgoing_recovery ON outgoing(retry_until,created_at,key) WHERE result IS NULL AND failure IS NULL');
71
77
  const stored = this.getState('identity');
72
78
  if (stored && canonical(stored) !== canonical(this.identity)) throw new LocalError('session_mismatch');
73
79
  if (!stored) this.setState('identity', this.identity);
@@ -234,33 +240,62 @@ export class Inbox {
234
240
  });
235
241
  }
236
242
 
237
- prepareSend(rawRequest, { key = randomUUID(), retryWindowSeconds = 86400 } = {}) {
238
- key = validateIdempotencyKey(key);
239
- const request = validateSend(rawRequest);
243
+ prepareSend(rawRequest, options = {}) {
244
+ return this.prepareOperation('send',validateSend(rawRequest),options);
245
+ }
246
+
247
+ prepareResend(messageId, input = {}, options = {}) {
248
+ const validated=validateResend(input);
249
+ return this.prepareOperation('resend',{message_id:validateUuid(messageId),...(input.ttl_seconds===undefined?{}:validated)},options);
250
+ }
251
+
252
+ prepareOperation(kind, request, { key, operationId, issuedAt=this.now(), retryUntil, retryWindowSeconds = 86400 } = {}) {
253
+ if(operationId!==undefined&&(typeof operationId!=='string'||!/^[a-f0-9]{64}$/.test(operationId)))throw new LocalError('invalid_configuration');
254
+ const mapped=operationId?this.outgoingForOperation(operationId):null;
255
+ key = validateIdempotencyKey(key??mapped?.key??`op_${issuedAt}_${randomUUID().replaceAll('-','')}`);
240
256
  const encoded = canonical(request);
241
257
  const hash = digest(request);
242
258
  const existing = this.outgoing(key);
243
259
  if (existing) {
244
- if (existing.digest !== hash) throw new LocalError('send_conflict');
260
+ if (existing.digest !== hash || existing.kind !== kind) throw new LocalError('send_conflict');
245
261
  return key;
246
262
  }
263
+ const started=/^op_([0-9]{13})_/.exec(key);
264
+ if(started&&issuedAt-Number(started[1])>=retryWindowSeconds*1000)throw new LocalError('retry_window_elapsed',{effect:'unknown'});
265
+ if(retryUntil!==undefined&&(!Number.isSafeInteger(retryUntil)||retryUntil<=this.now()))throw new LocalError('retry_window_elapsed',{effect:'unknown'});
247
266
  if (!Number.isSafeInteger(retryWindowSeconds) || retryWindowSeconds < 1) throw new LocalError('invalid_configuration');
248
267
  this.transaction(() => {
268
+ this.db.prepare('DELETE FROM outgoing WHERE key IN (SELECT key FROM outgoing WHERE (result IS NOT NULL OR failure IS NOT NULL) AND retry_until<? LIMIT 100)').run(this.now());
249
269
  if (this.db.prepare('SELECT COUNT(*) AS n FROM outgoing').get().n >= this.limits.maxOutgoingCount) throw new LocalError('inbox_full');
250
- this.db.prepare('INSERT INTO outgoing(key,digest,request,created_at,retry_until) VALUES(?,?,?,?,?)').run(key, hash, encoded, this.now(), this.now() + retryWindowSeconds * 1000);
270
+ this.db.prepare('INSERT INTO outgoing(key,digest,request,created_at,retry_until,kind,operation_id) VALUES(?,?,?,?,?,?,?)').run(key, hash, encoded, this.now(), Math.min(retryUntil??Infinity,this.now()+retryWindowSeconds*1000),kind,operationId??null);
251
271
  }, { growth: Buffer.byteLength(encoded) * 3 + 8192 });
252
272
  return key;
253
273
  }
254
274
 
275
+ outgoingForOperation(operationId) {
276
+ const row=this.db.prepare('SELECT key FROM outgoing WHERE operation_id=?').get(operationId);
277
+ return row?this.outgoing(row.key):null;
278
+ }
279
+
255
280
  outgoing(key) {
256
281
  this.assertUsable();
257
282
  const row = this.db.prepare('SELECT * FROM outgoing WHERE key=?').get(key);
258
- return row ? { ...row, request: JSON.parse(row.request), result: row.result ? JSON.parse(row.result) : null } : null;
283
+ return row ? { ...row, request: JSON.parse(row.request), result: row.result ? JSON.parse(row.result) : null, failure: row.failure ? JSON.parse(row.failure) : null } : null;
259
284
  }
260
285
 
261
286
  unresolvedSends() {
262
287
  this.assertUsable();
263
- return this.db.prepare('SELECT key,created_at,retry_until FROM outgoing WHERE result IS NULL ORDER BY created_at LIMIT 100').all().map(row => ({ ...row }));
288
+ return this.db.prepare('SELECT key,created_at,retry_until FROM outgoing WHERE result IS NULL AND failure IS NULL ORDER BY created_at LIMIT 100').all().map(row => ({ ...row }));
289
+ }
290
+
291
+ outgoingRecovery(limit = 10, now = this.now()) {
292
+ this.assertUsable();
293
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100 || !Number.isSafeInteger(now)) throw new LocalError('invalid_configuration');
294
+ // Filter before LIMIT: retained, expired unknowns must never starve live retries.
295
+ // Earliest deadline first uses the partial index without sorting/scanning old rows.
296
+ const operations = this.db.prepare('SELECT key,created_at,retry_until FROM outgoing WHERE result IS NULL AND failure IS NULL AND retry_until>? ORDER BY retry_until,created_at,key LIMIT ?').all(now, limit).map(row => ({ ...row }));
297
+ const expiredCount = this.db.prepare('SELECT count(*) AS n FROM outgoing WHERE result IS NULL AND failure IS NULL AND retry_until<=?').get(now).n;
298
+ return { operations, expiredCount };
264
299
  }
265
300
 
266
301
  recordSendResult(key, result) {
@@ -268,15 +303,25 @@ export class Inbox {
268
303
  if (Buffer.byteLength(encoded) > 16384) throw new LocalError('invalid_response', { effect: 'unknown' });
269
304
  this.transaction(() => {
270
305
  if (!this.outgoing(key)) throw new LocalError('invalid_configuration');
271
- this.db.prepare('UPDATE outgoing SET result=? WHERE key=?').run(encoded, key);
306
+ this.db.prepare('UPDATE outgoing SET result=?,failure=NULL,uncertain=0 WHERE key=?').run(encoded, key);
272
307
  }, { growth: Buffer.byteLength(encoded) * 2 + 8192 });
273
308
  }
274
309
 
310
+ markOutgoingAttempt(key) {
311
+ this.transaction(()=>this.db.prepare('UPDATE outgoing SET uncertain=1 WHERE key=?').run(key));
312
+ }
313
+
314
+ recordSendFailure(key, failure) {
315
+ const encoded=canonical(failure);
316
+ if(Buffer.byteLength(encoded)>16384)throw new LocalError('invalid_response',{effect:'unknown'});
317
+ this.transaction(()=>this.db.prepare('UPDATE outgoing SET failure=?,uncertain=0 WHERE key=? AND result IS NULL').run(encoded,key),{growth:Buffer.byteLength(encoded)*2+8192});
318
+ }
319
+
275
320
  collect(limit = 100) {
276
321
  if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000) throw new LocalError('invalid_configuration');
277
322
  return this.transaction(() => {
278
323
  const removedInbox = this.db.prepare("DELETE FROM inbox WHERE message_id IN (SELECT message_id FROM inbox WHERE state='consumed' AND retain_until<? ORDER BY retain_until LIMIT ?)").run(this.now(), limit).changes;
279
- const removedSends = this.db.prepare('DELETE FROM outgoing WHERE key IN (SELECT key FROM outgoing WHERE result IS NOT NULL AND retry_until<? ORDER BY retry_until LIMIT ?)').run(this.now(), limit).changes;
324
+ const removedSends = this.db.prepare('DELETE FROM outgoing WHERE key IN (SELECT key FROM outgoing WHERE (result IS NOT NULL OR failure IS NOT NULL) AND retry_until<? ORDER BY retry_until LIMIT ?)').run(this.now(), limit).changes;
280
325
  return { removed_inbox: removedInbox, removed_sends: removedSends };
281
326
  });
282
327
  }
@@ -13,8 +13,12 @@ const metadata = object({ name: text(128), model, project: { type: ['string', 'n
13
13
 
14
14
  export const TOOL_DEFINITIONS = Object.freeze([
15
15
  { name: 'broker_list_clients', description: 'List authorized exact session addresses. Names and models are descriptive, not routing authority.', inputSchema: object({ connected: { type: 'boolean' }, client_type: { enum: ['pi', 'antigravity-cli', 'claude-code', 'opencode'] }, name: text(128), project: text(256), cursor: text(256), limit: { type: 'integer', minimum: 1, maximum: 100 } }) },
16
- { name: 'broker_send', description: 'Send opaque untrusted AGENT text to one exact client_id. Acceptance means broker storage only; preserve send_key on uncertain results.', inputSchema: object({ recipient_id: uuid, text: text(65536), ttl_seconds: { type: 'integer', minimum: 60, maximum: 604800 }, correlation_id: text(128), reply_to_message_id: uuid, send_key: text(128) }, ['recipient_id', 'text']) },
16
+ { name: 'broker_send', description: 'Send opaque untrusted AGENT text to one exact client_id. Acceptance means broker storage only; internal operation identity handles technical retries.', inputSchema: object({ recipient_id: uuid, text: text(65536), ttl_seconds: { type: 'integer', minimum: 60, maximum: 604800 }, correlation_id: text(128), reply_to_message_id: uuid }, ['recipient_id', 'text']) },
17
17
  { name: 'broker_update_metadata', description: 'CAS update of descriptive name/model/project only. Metadata does not grant access or change identity.', inputSchema: object({ metadata_patch: metadata, expected_metadata_revision: { type: 'integer', minimum: 1 } }, ['metadata_patch', 'expected_metadata_revision']) },
18
+ { name: 'broker_sent_history', description: 'Read only this exact session’s outgoing metadata, newest first. Default 10, maximum 100, 32768-byte cap. No content. Preserve filters on continuation; restart on history_changed.', inputSchema: object({ recipient_id: uuid, status: { enum: ['queued','claimed','delivered','expired','failed','cancelled'] }, accepted_after: text(24), accepted_before: text(24), cursor: text(1024), limit: { type: 'integer', minimum: 1, maximum: 100 } }) },
19
+ { name: 'broker_message_content', description: 'Explicitly retrieve the original content and current transport receipt of one owned outgoing message.', inputSchema: object({ message_id: uuid }, ['message_id']) },
20
+ { name: 'broker_resend', description: 'Explicit new delivery of an owned retained message, with a new message ID. May duplicate a prior delivery; never an automatic technical retry.', inputSchema: object({ message_id: uuid, ttl_seconds: { type: 'integer', minimum: 60, maximum: 604800 } }, ['message_id']) },
21
+ { name: 'broker_cancel', description: 'Stop further pending delivery of one owned message. Cannot undo inbox content; inspect uncertainty and cancel_result.', inputSchema: object({ message_id: uuid }, ['message_id']) },
18
22
  { name: 'broker_message_status', description: 'Read broker delivery state, never model progress or task completion.', inputSchema: object({ message_id: uuid }, ['message_id']) },
19
23
  { name: 'broker_receive', description: 'Cooperatively peek at the oldest durable AGENT inbox item during this tool call. No unsolicited wakeup or native-consumption receipt; the item remains pending.', inputSchema: object() },
20
24
  ]);
@@ -1,15 +1,25 @@
1
+ import { digest } from '../src/protocol.mjs';
1
2
  import { LocalError, safeError } from './errors.mjs';
2
3
  import { provenance } from './runtime.mjs';
3
4
 
4
5
  /** Native host tool facade, not an MCP transport or a generic TUI injection API. */
5
6
  export function createTools({ client, inbox = client?.inbox, observeMetadata = null, cooperativeBoundary = null }) {
6
7
  if (!client || !inbox) throw new LocalError('invalid_configuration');
8
+ const internalOperation=(kind,context)=>{
9
+ if(context?.operationId!==undefined){
10
+ if(typeof context.operationId!=='string'||!context.operationId||context.operationId.length>1024)throw new LocalError('invalid_configuration');
11
+ const operationId=digest({kind,operation_id:context.operationId}),existing=inbox.outgoingForOperation(operationId);
12
+ return {operationId,...(existing?{key:existing.key}:{}),...(context.operationStartedAt===undefined?{}:{operationStartedAt:context.operationStartedAt})};
13
+ }
14
+ return {};
15
+ };
16
+ const publicResult=value=>{const {send_key,...result}=value;return result;};
7
17
  return {
8
- async broker_list_clients(arguments_ = {}) { return client.listClients(arguments_); },
9
- async broker_send(arguments_) {
18
+ async broker_list_clients(arguments_ = {}, context = {}) { return client.listClients(arguments_,{signal:context.signal}); },
19
+ async broker_send(arguments_, context = {}) {
10
20
  if (!arguments_ || typeof arguments_ !== 'object' || Array.isArray(arguments_)
11
- || Object.keys(arguments_).some(key => !['recipient_id', 'text', 'ttl_seconds', 'correlation_id', 'reply_to_message_id', 'send_key'].includes(key))) throw new LocalError('invalid_configuration');
12
- let key = arguments_.send_key;
21
+ || Object.keys(arguments_).some(key => !['recipient_id', 'text', 'ttl_seconds', 'correlation_id', 'reply_to_message_id'].includes(key))) throw new LocalError('invalid_configuration');
22
+ const operation=internalOperation('send',context);let key=operation.key;
13
23
  try {
14
24
  const request = { recipient_id: arguments_.recipient_id, content: { type: 'text/plain', text: arguments_.text } };
15
25
  for (const field of ['ttl_seconds', 'correlation_id', 'reply_to_message_id']) if (Object.hasOwn(arguments_, field)) request[field] = arguments_[field];
@@ -21,21 +31,35 @@ export function createTools({ client, inbox = client?.inbox, observeMetadata = n
21
31
  } else if (!original && observeMetadata) {
22
32
  const observed = await observeMetadata();
23
33
  if (observed) {
24
- await client.refreshOwnedMetadata();
34
+ await client.refreshOwnedMetadata({signal:context.signal});
25
35
  request.metadata_patch = observed; request.expected_metadata_revision = client.attachment.metadata_revision;
26
36
  }
27
37
  }
28
- key = client.prepareSend(request, key ? { key } : {});
29
- return await client.sendPrepared(key);
30
- } catch (error) { return { ...safeError(error), send_key: key ?? null }; }
38
+ key = client.prepareSend(request,operation);
39
+ return publicResult(await client.sendPrepared(key,{signal:context.signal}));
40
+ } catch (error) { return safeError(error); }
31
41
  },
32
- async broker_update_metadata(arguments_) {
42
+ async broker_update_metadata(arguments_, context = {}) {
33
43
  if (!arguments_ || Object.keys(arguments_).some(key => !['metadata_patch', 'expected_metadata_revision'].includes(key))) throw new LocalError('invalid_configuration');
34
- return client.updateMetadata(arguments_.metadata_patch, { expectedRevision: arguments_.expected_metadata_revision });
44
+ return client.updateMetadata(arguments_.metadata_patch, { expectedRevision: arguments_.expected_metadata_revision, signal:context.signal });
45
+ },
46
+ async broker_sent_history(arguments_ = {}, context = {}) { return client.sentHistory(arguments_,{signal:context.signal}); },
47
+ async broker_resend(arguments_, context = {}) {
48
+ if(!arguments_||Object.keys(arguments_).some(key=>!['message_id','ttl_seconds'].includes(key)))throw new LocalError('invalid_configuration');
49
+ const operation=internalOperation('resend',context),{message_id,...input}=arguments_;
50
+ return publicResult(await client.resendPrepared(client.prepareResend(message_id,input,operation),{signal:context.signal}));
51
+ },
52
+ async broker_cancel(arguments_, context = {}) {
53
+ if(!arguments_||Object.keys(arguments_).join(',')!=='message_id')throw new LocalError('invalid_configuration');
54
+ return client.cancelMessage(arguments_.message_id,{signal:context.signal});
55
+ },
56
+ async broker_message_content(arguments_, context = {}) {
57
+ if(!arguments_||Object.keys(arguments_).join(',')!=='message_id') throw new LocalError('invalid_configuration');
58
+ return client.messageContent(arguments_.message_id,{signal:context.signal});
35
59
  },
36
- async broker_message_status(arguments_) {
60
+ async broker_message_status(arguments_, context = {}) {
37
61
  if (!arguments_ || Object.keys(arguments_).join(',') !== 'message_id') throw new LocalError('invalid_configuration');
38
- return client.messageStatus(arguments_.message_id);
62
+ return client.messageStatus(arguments_.message_id,{signal:context.signal});
39
63
  },
40
64
  async broker_receive(arguments_ = {}) {
41
65
  if (Object.keys(arguments_).length || typeof cooperativeBoundary !== 'function') throw new LocalError('unsafe_boundary');
@@ -52,8 +76,8 @@ export function createTools({ client, inbox = client?.inbox, observeMetadata = n
52
76
  };
53
77
  }
54
78
 
55
- export async function invokeTool(tools, name, arguments_) {
79
+ export async function invokeTool(tools, name, arguments_, context = {}) {
56
80
  if (!Object.hasOwn(tools, name) || typeof tools[name] !== 'function') return new LocalError('unknown_tool').toJSON();
57
- try { return await tools[name](arguments_); }
81
+ try { return await tools[name](arguments_,context); }
58
82
  catch (error) { return safeError(error); }
59
83
  }
@@ -17,6 +17,9 @@ const ERROR_TEXT = Object.freeze({
17
17
  forbidden: ['This operation is not authorized.', 'Check the enrolled adapter permissions.'],
18
18
  recipient_not_found: ['The recipient is not available.', 'Select an exact authorized client ID from discovery.'],
19
19
  message_not_found: ['The message is not available.', 'Use a message ID owned by this principal.'],
20
+ retry_window_elapsed: ['The operation recovery record has expired.', 'Reconcile the existing receipt; only an explicit new send or resend may create another delivery.'],
21
+ history_changed: ['History changed while paging.', 'Restart the listing without a cursor; no continuation was silently skipped.'],
22
+ content_unavailable: ['The original content is no longer retained.', 'Use the receipt; unavailable content cannot be reconstructed.'],
20
23
  identity_conflict: ['The session identity conflicts with an existing registration.', 'Do not adopt another session; verify the local session identity.'],
21
24
  stale_lease_epoch: ['Reconnect this session before retrying.', 'Read owned registration state and reconnect; do not blindly replay a mutation.'],
22
25
  idempotency_conflict: ['This key was already used for a different request.', 'Reconcile the original request; do not change an uncertain request.'],
@@ -297,3 +300,19 @@ export function canonical(value) {
297
300
  export function digest(value) {
298
301
  return createHash('sha256').update(canonical(value), 'utf8').digest('hex');
299
302
  }
303
+
304
+ export function validateResend(value, { limits = {} } = {}) {
305
+ object(value, ['ttl_seconds'], [], 'body');
306
+ const merged = { ...DEFAULTS, ...limits };
307
+ return { ttl_seconds: value.ttl_seconds === undefined ? merged.default_ttl_seconds : integer(value.ttl_seconds, Math.max(60, merged.min_ttl_seconds), Math.min(604800, merged.max_ttl_seconds), 'body.ttl_seconds') };
308
+ }
309
+
310
+ /** New internal keys carry a timestamp; legacy keys retain their bounded v1 guarantee. */
311
+ export function validateOperationTime(key, now, retryWindowSeconds) {
312
+ if(!key.startsWith('op_'))return;
313
+ const match=/^op_([0-9]{13})_([A-Za-z0-9_-]{16,96})$/.exec(key);
314
+ if(!match)throw new ApiError(400,'invalid_request','headers.Idempotency-Key');
315
+ const started=Number(match[1]);
316
+ if(started>now+300000)throw new ApiError(400,'invalid_request','headers.Idempotency-Key');
317
+ if(now-started>=retryWindowSeconds*1000)throw new ApiError(410,'retry_window_elapsed','headers.Idempotency-Key');
318
+ }