@matuto-ai/opencode-broker-connector 0.1.1 → 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 (55) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +61 -43
  3. package/bin/doctor.mjs +5 -0
  4. package/docs/candidate-verification.md +25 -0
  5. package/docs/integration.md +8 -6
  6. package/docs/legacy-connection.md +77 -0
  7. package/docs/message-lifecycle.md +24 -0
  8. package/docs/native-automation.md +155 -0
  9. package/docs/native-v2.md +121 -0
  10. package/docs/owned-run.md +4 -2
  11. package/docs/publishing.md +5 -5
  12. package/docs/reference-snapshot.sha256 +5 -5
  13. package/docs/sandbox-persistence.md +43 -0
  14. package/docs/verification.md +62 -0
  15. package/package.json +15 -4
  16. package/plugin/index.mjs +14 -2
  17. package/plugin/tui.mjs +17 -0
  18. package/plugin/v2.mjs +18 -0
  19. package/src/approval-helper.mjs +11 -0
  20. package/src/capabilities.mjs +13 -0
  21. package/src/config.mjs +5 -2
  22. package/src/cooperative-inbox.mjs +13 -8
  23. package/src/enrollment.mjs +52 -0
  24. package/src/installation.mjs +123 -0
  25. package/src/message-format.mjs +26 -0
  26. package/src/native-context.mjs +57 -0
  27. package/src/native-delivery.mjs +151 -0
  28. package/src/native-plugin.mjs +135 -13
  29. package/src/opencode-adapter.mjs +31 -15
  30. package/src/pending-resume.mjs +38 -0
  31. package/src/reference-pin.mjs +6 -6
  32. package/src/reference.mjs +1 -1
  33. package/src/setup-ui.mjs +100 -0
  34. package/src/tool-catalog.mjs +7 -2
  35. package/src/tool-scope.mjs +26 -0
  36. package/src/v2-delivery.mjs +149 -0
  37. package/src/v2-inbox.mjs +33 -0
  38. package/src/v2-lock.mjs +17 -0
  39. package/src/v2-plugin.mjs +192 -0
  40. package/src/v2-setup.mjs +133 -0
  41. package/v2/LICENSE +21 -0
  42. package/v2/README.md +17 -0
  43. package/v2/index.mjs +2 -0
  44. package/v2/package-lock.json +420 -0
  45. package/v2/package.json +13 -0
  46. package/vendor/broker/ORIGIN.md +11 -4
  47. package/vendor/broker/reference/client.mjs +90 -15
  48. package/vendor/broker/reference/inbox.mjs +55 -10
  49. package/vendor/broker/reference/mcp-stdio.mjs +5 -1
  50. package/vendor/broker/reference/tools.mjs +38 -14
  51. package/vendor/broker/src/protocol.mjs +19 -0
  52. package/vendor/enrollment/LICENSE +21 -0
  53. package/vendor/enrollment/ORIGIN.md +20 -0
  54. package/vendor/enrollment/approval.mjs +181 -0
  55. package/vendor/enrollment/enrollment.mjs +137 -0
@@ -1,8 +1,9 @@
1
+ import { createToolScope } from './tool-scope.mjs';
1
2
  import { createCooperativeInbox } from './cooperative-inbox.mjs';
2
- import { ACK_DEFINITION } from './tool-catalog.mjs';
3
+ import { ACK_DEFINITION, DIAGNOSTIC_RECEIVE_DEFINITION } from './tool-catalog.mjs';
3
4
  import { isAbsolute, normalize } from 'node:path';
4
5
 
5
- const TOOL_NAMES = ['broker_list_clients', 'broker_send', 'broker_update_metadata', 'broker_message_status', 'broker_receive'];
6
+ const TOOL_NAMES = ['broker_list_clients', 'broker_send', 'broker_update_metadata', 'broker_message_status', 'broker_receive', 'broker_sent_history', 'broker_message_content', 'broker_resend', 'broker_cancel'];
6
7
  const localId = value => typeof value === 'string' && /^[a-zA-Z0-9_.:-]{1,256}$/.test(value);
7
8
  const absolute = value => typeof value === 'string' && isAbsolute(value) && normalize(value) === value;
8
9
  const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
@@ -62,13 +63,15 @@ function displayJSON(value) {
62
63
  * are insufficient lifetime proof. No automatic plugin attachment is implied.
63
64
  */
64
65
  export function createOpenCodeAdapter({ reference, tool, binding, currentBinding,
65
- client, inbox = client?.inbox, observeMetadata = null, onDiagnostic = () => {}, cooperativeAck = false }) {
66
+ client, inbox = client?.inbox, observeMetadata = null, onDiagnostic = () => {}, cooperativeAck = false, operationContext = null, closeSessionOnStop = true }) {
66
67
  if (!reference || typeof reference.AdapterRuntime !== 'function' || typeof reference.createTools !== 'function'
67
68
  || typeof reference.invokeTool !== 'function' || typeof reference.safeError !== 'function'
68
69
  || typeof reference.LocalError !== 'function' || !Array.isArray(reference.TOOL_DEFINITIONS)
69
70
  || typeof tool !== 'function' || !tool.schema || !client || !inbox
70
71
  || !binding || !localId(binding.sessionID) || !absolute(binding.directory) || !absolute(binding.worktree)
71
72
  || typeof binding.lifetime !== 'symbol' || !(binding.signal instanceof AbortSignal)
73
+ || typeof closeSessionOnStop !== 'boolean'
74
+ || operationContext !== null && typeof operationContext !== 'function'
72
75
  || typeof currentBinding !== 'function' || observeMetadata !== null && typeof observeMetadata !== 'function'
73
76
  || inbox.identity?.client_type !== 'opencode' || client.inbox !== inbox) {
74
77
  throw new Error('Invalid adapter configuration: provide a verified exact host binding and shared broker reference objects.');
@@ -78,13 +81,17 @@ export function createOpenCodeAdapter({ reference, tool, binding, currentBinding
78
81
  || new Set(definitions.map(definition => definition.name)).size !== TOOL_NAMES.length) {
79
82
  throw new Error('Shared tool catalog changed; review it before enabling this adapter.');
80
83
  }
81
- const catalog = [...definitions, ...(cooperativeAck ? [ACK_DEFINITION] : [])].map(definition => {
84
+ const catalog = [...definitions, ...(cooperativeAck ? [ACK_DEFINITION] : [])].map(original => {
85
+ const definition = cooperativeAck && original.name === 'broker_receive' ? DIAGNOSTIC_RECEIVE_DEFINITION : original;
82
86
  const args = nativeShape(tool.schema, definition.inputSchema);
83
87
  return { definition, args, validator: tool.schema.object(args).strict() };
84
88
  });
85
89
  const expected = Object.freeze({ sessionID: binding.sessionID, directory: binding.directory,
86
90
  worktree: binding.worktree, lifetime: binding.lifetime, signal: binding.signal });
87
91
  const identity = Object.freeze({ ...inbox.identity });
92
+ const toolScope = createToolScope({ closedError: () => new reference.LocalError('session_mismatch', { effect: 'unknown' }), abortedError: () => new reference.LocalError('request_aborted') });
93
+ const recoveryAbort = new AbortController();
94
+ let recoveryPromise;
88
95
  let started = false, closed = false, startPromise, runtimeStartPromise, stopPromise;
89
96
  const diagnostic = code => { try { onDiagnostic({ code }); } catch { /* No raw exception data. */ } };
90
97
  function sameIdentity(candidate) {
@@ -104,16 +111,19 @@ export function createOpenCodeAdapter({ reference, tool, binding, currentBinding
104
111
  onDiagnostic: entry => diagnostic(entry.code) });
105
112
  async function stop() {
106
113
  if (stopPromise) return stopPromise;
107
- closed = true; started = false;
114
+ closed = true; started = false; recoveryAbort.abort();
115
+ const toolsClosed = toolScope.close();
108
116
  expected.signal.removeEventListener('abort', onClose);
117
+ // Fence transport lifetime synchronously, before any deferred tool drain.
118
+ const runtimeStopped = runtime.stop({ closeSession: false });
109
119
  stopPromise = Promise.resolve().then(async () => {
110
120
  try {
111
- await runtime.stop({ closeSession: false }); // Cancel, but do not close a stale pre-reconnect epoch.
121
+ await runtimeStopped; // Cancel, but do not close a stale pre-reconnect epoch.
112
122
  await runtimeStartPromise?.catch(() => {});
113
123
  // Registration may have committed after cancellation. Keep
114
124
  // its journal open until a second close fences that exact late lease.
115
- await runtime.stop();
116
- } finally { inbox.close(); }
125
+ await runtime.stop({ closeSession: closeSessionOnStop });
126
+ } finally { await toolsClosed; await recoveryPromise?.catch(() => {}); inbox.close(); }
117
127
  });
118
128
  return stopPromise;
119
129
  }
@@ -141,6 +151,11 @@ export function createOpenCodeAdapter({ reference, tool, binding, currentBinding
141
151
  runtimeStartPromise = runtime.start(metadata);
142
152
  const result = await runtimeStartPromise;
143
153
  await assertBound(undefined, 'unknown');
154
+ // No durable tool-call ID exists in this pinned ToolContext. Reconcile
155
+ // private operations after exact-session reattachment, never guess from text/messageID.
156
+ recoveryPromise = client.recoverOutgoing?.({ limit: 10, signal: AbortSignal.any([recoveryAbort.signal, expected.signal, AbortSignal.timeout(5000)]) });
157
+ await recoveryPromise?.catch(() => diagnostic('outgoing_recovery_incomplete'));
158
+ await assertBound(undefined, 'unknown');
144
159
  started = true;
145
160
  return result;
146
161
  } catch (error) {
@@ -153,6 +168,7 @@ export function createOpenCodeAdapter({ reference, tool, binding, currentBinding
153
168
  const tools = Object.fromEntries(catalog.map(({ definition, args, validator }) => {
154
169
  return [definition.name, tool({ description: definition.description, args,
155
170
  async execute(arguments_, context) {
171
+ return toolScope.run(async operationSignal => {
156
172
  try {
157
173
  if (!started || !context) throw new reference.LocalError('session_mismatch');
158
174
  await assertBound(context);
@@ -160,8 +176,9 @@ export function createOpenCodeAdapter({ reference, tool, binding, currentBinding
160
176
  if (!parsed.success) throw new reference.LocalError('invalid_configuration', { field: 'arguments' });
161
177
  // Per-invocation closure, never a mutable global current tool context.
162
178
  const scopedClient = { inbox, get attachment() { return client.attachment; },
163
- prepareSend: (...args_) => client.prepareSend(...args_) };
164
- for (const method of ['listClients', 'sendPrepared', 'updateMetadata', 'messageStatus', 'refreshOwnedMetadata']) {
179
+ prepareSend: (...args_) => client.prepareSend(...args_),
180
+ prepareResend: (...args_) => client.prepareResend(...args_) };
181
+ for (const method of ['listClients', 'sendPrepared', 'updateMetadata', 'messageStatus', 'refreshOwnedMetadata', 'resendPrepared', 'sentHistory', 'messageContent', 'cancelMessage']) {
165
182
  scopedClient[method] = async (...args_) => {
166
183
  await assertBound(context);
167
184
  return client[method](...args_);
@@ -170,20 +187,19 @@ export function createOpenCodeAdapter({ reference, tool, binding, currentBinding
170
187
  const facade = reference.createTools({ client: scopedClient, inbox, observeMetadata,
171
188
  cooperativeBoundary: async read => { await assertBound(context); return read(); } });
172
189
  if (cooperativeInbox) Object.assign(facade, cooperativeInbox);
173
- const result = await reference.invokeTool(facade, definition.name, parsed.data);
190
+ const result = await reference.invokeTool(facade, definition.name, parsed.data, { ...(operationContext ? operationContext(context) : {}), signal: AbortSignal.any([operationSignal, expected.signal]) });
174
191
  // Unknown/cancelled host results never get repackaged as acceptance.
175
- // A write may already have committed; retain its stable send key.
192
+ // The private outgoing ledger retains any uncertain operation and its key.
176
193
  try { await assertBound(context, 'unknown'); }
177
194
  catch (error) {
178
195
  const failure = reference.safeError(error);
179
- const sendKey = result?.send_key ?? parsed.data.send_key;
180
- if (definition.name === 'broker_send' && typeof sendKey === 'string') failure.send_key = sendKey;
181
196
  return displayJSON(failure);
182
197
  }
183
198
  return displayJSON(result);
184
199
  } catch (error) { return displayJSON(reference.safeError(error)); }
200
+ }, context?.abort).catch(error => displayJSON(reference.safeError(error)));
185
201
  },
186
202
  })];
187
203
  }));
188
- return Object.freeze({ tools, start, stop, localMode: 'cooperative_pull' });
204
+ return Object.freeze({ tools, start, stop, localMode: 'diagnostic_pull' });
189
205
  }
@@ -0,0 +1,38 @@
1
+ import { loadApprovalHelper } from './approval-helper.mjs';
2
+ import { setTimeout as delay } from 'node:timers/promises';
3
+ import { installationDirectory, readPending, updatePending, selectInstallation } from './installation.mjs';
4
+ /** GET-only continuation of an explicitly persisted request. Never creates credentials. */
5
+ export function startPendingResume({ directory = installationDirectory(), loadHelper = loadApprovalHelper,
6
+ pause = (signal, ms = 2000) => delay(ms, undefined, { signal }), select = selectInstallation } = {}) {
7
+ const controller = new AbortController();
8
+ const done = (async () => {
9
+ let pending;
10
+ try { pending=readPending(directory); } catch { return; }
11
+ if(!pending || pending.paused)return;
12
+ const flowId=pending.flowId;
13
+ const helper=await loadHelper();
14
+ while(!controller.signal.aborted) {
15
+ let waitMs=2000;
16
+ try {
17
+ pending=readPending(directory);
18
+ if(!pending||pending.flowId!==flowId||pending.paused)return;
19
+ const options={directory,brokerUrl:pending.brokerUrl,clientType:'opencode'};
20
+ const saved=helper.readApprovalState(options);
21
+ if(!saved || pending.requestId && saved.requestId!==pending.requestId)return;
22
+ const result=await helper.pollApproval(options); // Deliberately no beginApproval anywhere in this module.
23
+ if(controller.signal.aborted)return;
24
+ if(result.requestId!==saved.requestId)return;
25
+ if(result.state==='approved') {
26
+ await select({directory,connectionFile:result.connectionFile,expectedRevision:pending.expectedRevision,pendingFlowId:flowId,signal:controller.signal});
27
+ return;
28
+ }
29
+ if(result.state!=='pending') { updatePending({directory,flowId,paused:true});return; }
30
+ } catch(error) {
31
+ if(!error.retryable)return; // Invalid local/auth state is inert; never reset or replace it.
32
+ waitMs=Math.min(300000,Math.max(2000,error.retryAfterMs||2000));
33
+ }
34
+ await pause(controller.signal,waitMs);
35
+ }
36
+ })().catch(()=>{});
37
+ return {done,stop:()=>controller.abort()};
38
+ }
@@ -1,15 +1,15 @@
1
1
  import { readFileSync, lstatSync } from 'node:fs';
2
2
  import { join, isAbsolute } from 'node:path';
3
3
  import { createHash } from 'node:crypto';
4
- export const REFERENCE_COMMIT = 'ba2219cd87a1feac23e01b23d2c2b3d3bdd65359';
4
+ export const REFERENCE_COMMIT = '2de9cc3758b9fc593f5afca4fc501fd013d416ec';
5
5
  const HASHES = Object.freeze({
6
- "reference/inbox.mjs": "93f75786eab29429ace024974e0f61d6ad46d2c5fe065dae387e54fd2373b167",
7
- "reference/client.mjs": "f661b45acba106be03d97dacba3cd90ac18b09e7ff9eb93b995d5036d9d1c785",
6
+ "reference/inbox.mjs": "fbf0fa6096996cb9bc3f928e55f3eca496420880a03460e993dda3417b32ad88",
7
+ "reference/client.mjs": "c4c313e937d1003f28928a672072b926022aa0e9fd65825bc5db1078eb90afa9",
8
8
  "reference/runtime.mjs": "8733076b73a42ab8f12fe20ba9908acdd754d89734ffd335e84b49aee9d737ca",
9
- "reference/tools.mjs": "5e6355cef1bec56170dca14b93142a7da8c102f411c5dbf437d84acda7351acf",
10
- "reference/mcp-stdio.mjs": "10a93788af426ce898c398e38dd41ac77b6e0129a7697bdc36c8d4f6d6e20138",
9
+ "reference/tools.mjs": "5d3b16212a048912395d74b95a8b590d278b8b0915bb0bd7a0c3d80daf353839",
10
+ "reference/mcp-stdio.mjs": "f34b0cdcda9c2d607815e4ef99f1ba80fbd8936ef8c2893a858b6d408bc0ff09",
11
11
  "reference/errors.mjs": "e33231d94f514b5a1b59fdd4184f5881dd3712bde725a1cf53352c26293452ac",
12
- "src/protocol.mjs": "f3ff24b4429dddc8a9c10513ab72dbc613c91244591132458c7866a812d6316b"
12
+ "src/protocol.mjs": "2d88829971643d47430d6696d0a6ac61589ea4f9001c860c5c196ecc5ac21a6f"
13
13
  });
14
14
  /** Verify reviewed source bytes before importing any trusted local module. */
15
15
  export function verifyReference(directory) {
package/src/reference.mjs CHANGED
@@ -2,7 +2,7 @@ import { verifyReference } from './reference-pin.mjs';
2
2
  import { isAbsolute, join } from 'node:path';
3
3
  import { fileURLToPath, pathToFileURL } from 'node:url';
4
4
 
5
- export const BROKER_REFERENCE_COMMIT = 'ba2219cd87a1feac23e01b23d2c2b3d3bdd65359';
5
+ export const BROKER_REFERENCE_COMMIT = '2de9cc3758b9fc593f5afca4fc501fd013d416ec';
6
6
 
7
7
  /** Trusted operator path to a pinned broker checkout; never a model argument. */
8
8
  export async function loadBrokerReference(directory = fileURLToPath(new URL('../vendor/broker', import.meta.url))) {
@@ -0,0 +1,100 @@
1
+ import { loadApprovalHelper } from './approval-helper.mjs';
2
+ import { setTimeout as delay } from 'node:timers/promises';
3
+ import { installationDirectory, readSelection, selectInstallation, savePending, updatePending } from './installation.mjs';
4
+
5
+ /** Host-owned form, not a model tool or a conversation command. */
6
+ export async function installSetupUI(api, { directory = installationDirectory(), loadHelper = loadApprovalHelper,
7
+ select = selectInstallation, pause = (signal, ms = 2000) => delay(ms, undefined, { signal }) } = {}) {
8
+ let current, pending;
9
+ const cancel = (pause = true) => {
10
+ current?.abort(); current = undefined;
11
+ if (pause && pending) { try { updatePending({ directory, flowId: pending.flowId, paused: true }); } catch {} }
12
+ pending = undefined;
13
+ };
14
+ api.lifecycle.onDispose(() => cancel(false));
15
+ function toast(message, variant = 'info') { api.ui.toast({ title: 'Psst', message, variant, duration: 6000 }); }
16
+ function start() {
17
+ cancel();
18
+ const controller = new AbortController(); current = controller;
19
+ const alive = () => current === controller && !controller.signal.aborted && !api.lifecycle.signal.aborted;
20
+ let submitted = false, transitioning = false;
21
+ const close = () => { if (!transitioning && current === controller) cancel(); };
22
+ function replace(render) { transitioning = true; try { api.ui.dialog.replace(render, close); } finally { transitioning = false; } }
23
+ replace(() => api.ui.DialogPrompt({
24
+ title: 'Psst setup — broker URL', placeholder: 'https://broker.example',
25
+ onCancel: close,
26
+ onConfirm: async value => {
27
+ if (!alive() || submitted) return;
28
+ submitted = true;
29
+ try {
30
+ const brokerUrl = new URL(value.trim());
31
+ if (brokerUrl.protocol !== 'https:' || brokerUrl.username || brokerUrl.password || brokerUrl.search || brokerUrl.hash || brokerUrl.pathname !== '/') throw new Error();
32
+ const helper = await loadHelper();
33
+ if (!alive()) return;
34
+ const selected = readSelection(directory);
35
+ const expectedRevision = selected?.revision ?? null;
36
+ const options = { directory, brokerUrl: brokerUrl.href, clientType: 'opencode' };
37
+ const prior = helper.readApprovalState(options);
38
+ async function enroll(replaceRequestId) {
39
+ if (!alive()) return;
40
+ pending = savePending({ directory, brokerUrl: brokerUrl.href, expectedRevision, requestId: replaceRequestId ? null : prior?.requestId ?? null });
41
+ const flowId = pending.flowId;
42
+ replace(() => api.ui.DialogAlert({ title: 'Psst approval pending',
43
+ message: 'Preparing the durable request. Close to pause; setup resumes the same request.',
44
+ onConfirm: close }));
45
+ async function retry(operation) {
46
+ while (alive()) {
47
+ try { return await operation(); }
48
+ catch (error) { if (!error.retryable) throw error; await pause(controller.signal, Math.min(300000, Math.max(2000, error.retryAfterMs || 2000))); }
49
+ }
50
+ throw new Error('psst_setup_cancelled');
51
+ }
52
+ let result = await retry(() => helper.beginApproval({ ...options, ...(replaceRequestId ? { replaceRequestId } : {}) }));
53
+ const requestId = result.requestId;
54
+ if (!replaceRequestId && prior?.state === 'pending' && requestId !== prior.requestId) throw new Error('psst_request_changed');
55
+ if (alive()) {
56
+ updatePending({ directory, flowId, requestId });
57
+ if (result.state === 'pending') replace(() => api.ui.DialogAlert({
58
+ title: 'Psst approval pending',
59
+ message: `Open ${new URL('/admin', brokerUrl).href}
60
+ Request ID: ${requestId}
61
+ Approve only the matching OpenCode request. Close to pause; setup resumes this same request.`,
62
+ onConfirm: close }));
63
+ }
64
+ while (alive() && result.state === 'pending') {
65
+ await pause(controller.signal);
66
+ if (alive()) {
67
+ result = await retry(() => helper.pollApproval(options));
68
+ if (result.requestId !== requestId) throw new Error('psst_request_changed');
69
+ }
70
+ }
71
+ if (!alive()) return;
72
+ if (result.state !== 'approved') { toast(`Request ${result.state}. Run setup explicitly to request replacement.`, 'warning'); close(); api.ui.dialog.clear(); return; }
73
+ await select({ directory, connectionFile: result.connectionFile, expectedRevision, signal: controller.signal, pendingFlowId: flowId });
74
+ if (!alive()) return;
75
+ close(); api.ui.dialog.clear();
76
+ toast(process.env.PSST_CONFIG_FILE
77
+ ? 'Approved installation saved. The explicit PSST_CONFIG_FILE override still takes precedence; restart without it to use the saved approval. Existing connections are unchanged.'
78
+ : 'Approved installation saved; transport can reconnect. Automatic messaging is blocked on this classic host; /psst-status explains the missing capability. Manual receive is diagnostic/test-only.', 'warning');
79
+ }
80
+ if (prior && prior.state !== 'pending' && (prior.state !== 'approved' || selected?.connectionFile === prior.connectionFile)) {
81
+ replace(() => api.ui.DialogConfirm({ title: 'Replace Psst authorization?',
82
+ message: 'Request a new authorization? The current connection and unread messages remain unchanged until approval; existing sessions keep their connection.',
83
+ onCancel: close, onConfirm: () => {
84
+ // The native confirmation clears its dialog after this callback.
85
+ transitioning = true; queueMicrotask(() => { transitioning = false; void enroll(prior.requestId).catch(failed); });
86
+ } }));
87
+ } else await enroll();
88
+ } catch { failed(); }
89
+ function failed() {
90
+ if (!alive()) return;
91
+ toast('Setup paused. Check the URL, private configuration and broker availability; retry setup to resume the same request. No credentials were replaced.', 'warning');
92
+ cancel(false); api.ui.dialog.clear();
93
+ }
94
+ },
95
+ }));
96
+ }
97
+ api.keymap.registerLayer({ commands: [{ name: 'psst.setup', title: 'Psst: setup broker connection',
98
+ category: 'Psst', namespace: 'palette', slashName: 'psst-setup', run: start }],
99
+ bindings: [{ key: 'ctrl+alt+p', cmd: 'psst.setup', desc: 'Set up Psst broker connection' }] });
100
+ }
@@ -1,6 +1,11 @@
1
1
  export const ACK_DEFINITION = Object.freeze({ name: 'broker_acknowledge',
2
- description: 'Explicitly acknowledge reading the exact message returned by broker_receive, using its receipt_token. Advances your local inbox. This is not approval of its instructions, proof of action success, or a native injection receipt.',
2
+ description: 'Explicitly acknowledge reading the exact message returned by broker_receive, using its message_id. The connector resolves the previously exposed local receipt internally; omit receipt_token normally. A supplied legacy token must still match exactly. Advances your local inbox. This is not approval of its instructions, proof of action success, or a native injection receipt.',
3
3
  inputSchema: { type: 'object', additionalProperties: false, properties: {
4
4
  message_id: { type: 'string', format: 'uuid' }, receipt_token: { type: 'string', format: 'uuid' },
5
- }, required: ['message_id', 'receipt_token'] },
5
+ }, required: ['message_id'] },
6
+ });
7
+
8
+ export const DIAGNOSTIC_RECEIVE_DEFINITION = Object.freeze({ name: 'broker_receive',
9
+ description: 'Diagnostic/test-only manual read, only when the user explicitly requested a manual transport test. Set diagnostic:true. Production automatic readiness is blocked on this classic host; this is not a fallback. Read then explicitly acknowledge message_id.',
10
+ inputSchema: { type: 'object', additionalProperties: false, properties: { diagnostic: { type: 'boolean' } }, required: ['diagnostic'] },
6
11
  });
@@ -0,0 +1,26 @@
1
+ /** An owner's admitted tool calls must settle before its private ledger closes. */
2
+ export function createToolScope({ closedError = () => new Error('tool_scope_closed'), abortedError = () => new Error('tool_call_aborted') } = {}) {
3
+ const controller = new AbortController(), pending = new Set();
4
+ let closed = false, closing;
5
+ return Object.freeze({
6
+ run(task, callerSignal) {
7
+ if (closed) return Promise.reject(closedError());
8
+ const signal = callerSignal instanceof AbortSignal
9
+ ? AbortSignal.any([controller.signal, callerSignal]) : controller.signal;
10
+ const operation = Promise.resolve().then(() => {
11
+ if (closed) throw closedError();
12
+ if (signal.aborted) throw abortedError();
13
+ return task(signal);
14
+ });
15
+ pending.add(operation);
16
+ void operation.then(() => pending.delete(operation), () => pending.delete(operation));
17
+ return operation;
18
+ },
19
+ close() {
20
+ if (closing) return closing;
21
+ closed = true; controller.abort();
22
+ closing = Promise.allSettled([...pending]).then(() => {});
23
+ return closing;
24
+ },
25
+ });
26
+ }
@@ -0,0 +1,149 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { canonical } from '../vendor/broker/src/protocol.mjs';
3
+ import { nativeMessage, NATIVE_FORMAT } from './message-format.mjs';
4
+
5
+ export const V2_HOST_VERSION = '2.2.5';
6
+ const id = value => typeof value === 'string' && /^[A-Za-z0-9_.:-]{1,256}$/.test(value);
7
+ export function v2SessionBinding(info, hostID) {
8
+ if (!id(info?.id) || !id(hostID) || !id(info?.projectID)
9
+ || !Number.isSafeInteger(info?.time?.created) || info.time.created <= 0
10
+ || typeof info?.location?.directory !== 'string' || !info.location.directory.startsWith('/')) throw new Error('v2_session_binding_unavailable');
11
+ return { hostID, sessionID: info.id, created: info.time.created, projectID: info.projectID,
12
+ location: { directory: info.location.directory, ...(info.location.workspaceID ? { workspaceID: info.location.workspaceID } : {}) } };
13
+ }
14
+
15
+ /** Native V2 queue admission, never classic promptAsync or a human-role prompt.
16
+ * A successful receipt means durable native admission, NOT model consumption.
17
+ * No mutation retry: native cancellation removes its idempotency lookup row.
18
+ */
19
+ export function createV2Delivery({ session, inbox, binding, signal, verify, interval = 250,
20
+ requestTimeout = 2000, onDiagnostic = () => {} }) {
21
+ if (!session || ['synthetic', 'context'].some(key => typeof session[key] !== 'function')
22
+ || !(signal instanceof AbortSignal) || typeof verify !== 'function' || !id(binding?.sessionID)
23
+ || !Number.isSafeInteger(interval) || interval < 10 || interval > 60000
24
+ || !Number.isSafeInteger(requestTimeout) || requestTimeout < 10 || requestTimeout > 30000) throw new Error('v2_capability_unavailable');
25
+ inbox.transaction(() => inbox.db.exec('CREATE TABLE IF NOT EXISTS v2_state(key TEXT PRIMARY KEY, value TEXT NOT NULL)'));
26
+ const getState = key => { inbox.assertUsable(); const row = inbox.db.prepare('SELECT value FROM v2_state WHERE key=?').get(key); return row ? JSON.parse(row.value) : null; };
27
+ const setState = (key, value) => inbox.transaction(() => inbox.db.prepare('INSERT INTO v2_state(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value').run(key, canonical(value)));
28
+ const persisted = getState('v2_binding');
29
+ if (persisted && canonical(persisted) !== canonical(binding)) throw new Error('v2_session_mismatch');
30
+ if (!persisted) setState('v2_binding', binding);
31
+ inbox.transaction(() => inbox.db.exec(`CREATE TABLE IF NOT EXISTS v2_submissions (
32
+ broker_message_id TEXT PRIMARY KEY REFERENCES inbox(message_id) ON DELETE CASCADE,
33
+ native_id TEXT NOT NULL UNIQUE, injection_id TEXT NOT NULL, payload TEXT NOT NULL,
34
+ promoted INTEGER NOT NULL DEFAULT 0)`));
35
+ let stopped = false, running, timer, epoch = 0;
36
+ const active = () => !stopped && !signal.aborted;
37
+ const diagnostic = code => { try { onDiagnostic({ code }); } catch {} };
38
+ async function call(method, input, permitted = active) {
39
+ const bounded = AbortSignal.any([signal, AbortSignal.timeout(requestTimeout)]);
40
+ let listener;
41
+ try {
42
+ return await Promise.race([
43
+ Promise.resolve().then(() => { if (bounded.aborted || !permitted()) throw new Error('v2_request_aborted'); return method(input, { signal: bounded }); }),
44
+ new Promise((_, reject) => { listener = () => reject(new Error('v2_request_incomplete'));
45
+ if (bounded.aborted) listener(); else bounded.addEventListener('abort', listener, { once: true }); }),
46
+ ]);
47
+ } finally { bounded.removeEventListener('abort', listener); }
48
+ }
49
+ async function current() {
50
+ if (!active()) return null;
51
+ const info = await verify();
52
+ if (!active() || !info || canonical(v2SessionBinding(info, binding.hostID)) !== canonical(binding)
53
+ || info.revert || info.time?.archived) return null;
54
+ return info;
55
+ }
56
+ function exactPayload(candidate, target) {
57
+ return candidate && canonical(candidate) === target.payload;
58
+ }
59
+ function exactAdmission(receipt, target) {
60
+ return receipt?.id === target.native_id && receipt.sessionID === binding.sessionID
61
+ && receipt.type === 'synthetic' && receipt.delivery === 'queue' && exactPayload(receipt.payload, target);
62
+ }
63
+ function exactContext(message, target) {
64
+ return message?.id === target.native_id && message.type === 'synthetic'
65
+ && exactPayload({ text: message.text, description: message.description, metadata: message.metadata }, target);
66
+ }
67
+ async function contextProof(target) {
68
+ const messages = await call(session.context.bind(session), { sessionID: binding.sessionID });
69
+ if (!Array.isArray(messages)) return false;
70
+ return messages.some(message => exactContext(message, target));
71
+ }
72
+ function accept(head, target) {
73
+ inbox.recordNativeAcceptance(head.message_id, target.injection_id, {
74
+ accepted: true, receipt_id: `${binding.sessionID}/${target.native_id}`,
75
+ });
76
+ }
77
+ async function deliver() {
78
+ const observedEpoch = epoch;
79
+ const info = await current();
80
+ if (!info) return;
81
+ // Interrupt/failure is durable across reload. Never wake a cancelled or
82
+ // failed session just because the broker has another pending envelope.
83
+ const idle = info.time.idle ?? 0, busy = info.time.busy ?? 0;
84
+ if (['interrupted', 'failed'].includes(info.outcome) && idle >= busy || getState('v2_paused')) return;
85
+ const head = inbox.head();
86
+ if (!head) return;
87
+ if (head.state !== 'pending') {
88
+ const target = inbox.db.prepare('SELECT * FROM v2_submissions WHERE broker_message_id=?').get(head.message_id);
89
+ if (!target || target.injection_id !== head.injection_id) return;
90
+ if (await contextProof(target) && active() && observedEpoch === epoch && await current()) {
91
+ accept(head, target);
92
+ inbox.transaction(() => inbox.db.prepare('UPDATE v2_submissions SET promoted=1 WHERE broker_message_id=?').run(head.message_id));
93
+ diagnostic('native_admission_reconciled');
94
+ }
95
+ return;
96
+ }
97
+ // Bound native backlog to one outstanding item. A lost delivered event is
98
+ // recovered by exact read-only context inspection, never another submit.
99
+ const outstanding = inbox.db.prepare('SELECT * FROM v2_submissions WHERE promoted=0 ORDER BY rowid LIMIT 1').get();
100
+ if (outstanding) {
101
+ if (!await contextProof(outstanding)) return;
102
+ if (!active() || observedEpoch !== epoch || !await current()) return;
103
+ inbox.transaction(() => inbox.db.prepare('UPDATE v2_submissions SET promoted=1 WHERE broker_message_id=?').run(outstanding.broker_message_id));
104
+ }
105
+ if (!active() || observedEpoch !== epoch || !await current()) return;
106
+ const payload = { text: nativeMessage(head.envelope), description: 'Psst agent message', metadata: {
107
+ source: 'psst', broker_message_id: head.message_id, sender_client_id: head.envelope.sender.client_id, format: NATIVE_FORMAT,
108
+ } };
109
+ const target = { native_id: 'msg_psst_' + randomUUID().replaceAll('-', ''), injection_id: randomUUID(), payload: canonical(payload) };
110
+ // State and exact immutable payload are committed atomically before effect.
111
+ inbox.transaction(() => {
112
+ const now = inbox.head();
113
+ if (now?.message_id !== head.message_id || now.state !== 'pending') throw new Error('v2_inbox_changed');
114
+ inbox.db.prepare('INSERT INTO v2_submissions(broker_message_id,native_id,injection_id,payload) VALUES(?,?,?,?)')
115
+ .run(head.message_id, target.native_id, target.injection_id, target.payload);
116
+ inbox.db.prepare("UPDATE inbox SET state='injecting',injection_id=? WHERE message_id=?").run(target.injection_id, head.message_id);
117
+ }, { growth: Buffer.byteLength(target.payload) * 2 + 8192 });
118
+ // Even an aborted request may have reached the server. Leave its journal
119
+ // pinned, including on timeout, exception, cancellation or payload mismatch.
120
+ if (!active() || observedEpoch !== epoch) return;
121
+ try {
122
+ const receipt = await call(session.synthetic.bind(session), { sessionID: binding.sessionID,
123
+ id: target.native_id, ...payload, delivery: 'queue', resume: true }, () => active() && observedEpoch === epoch);
124
+ if (!active() || observedEpoch !== epoch || !await current()) return;
125
+ if (!exactAdmission(receipt, target)) { diagnostic('native_receipt_mismatch'); return; }
126
+ accept(head, target);
127
+ diagnostic('native_admission_confirmed');
128
+ } catch { diagnostic('native_admission_uncertain'); }
129
+ }
130
+ function tick() {
131
+ if (!active() || running) return running;
132
+ running = Promise.resolve().then(deliver).catch(() => diagnostic('native_delivery_paused')).finally(() => { running = null; });
133
+ return running;
134
+ }
135
+ return {
136
+ tick,
137
+ start() { if (!timer && active()) { timer = setInterval(tick, interval); timer.unref?.(); } },
138
+ event(event) {
139
+ if (!active() || event?.data?.sessionID !== binding.sessionID) return;
140
+ if (['session.execution.interrupted', 'session.execution.failed', 'session.inbox.cancelled', 'session.revert.staged'].includes(event.type)) {
141
+ epoch++; setState('v2_paused', event.type); diagnostic('native_delivery_paused');
142
+ }
143
+ // Only host-started work, not an idle poll, can reopen cancellation.
144
+ if (event.type === 'session.execution.started') setState('v2_paused', null);
145
+ if (['session.deleted', 'session.moved', 'session.archived'].includes(event.type)) { epoch++; stopped = true; clearInterval(timer); }
146
+ },
147
+ async stop() { stopped = true; epoch++; clearInterval(timer); await running; },
148
+ };
149
+ }
@@ -0,0 +1,33 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { openSync, closeSync, readFileSync, writeFileSync, fsyncSync, fstatSync, constants } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { canonical } from '../vendor/broker/src/protocol.mjs';
5
+ import { privateDirectory } from './config.mjs';
6
+
7
+ /** Reopen only this installation + native server store + exact native session.
8
+ * Exclusive Inbox ownership protects concurrent owners. Never reset a partial,
9
+ * foreign or unreadable manifest: its broker/native effects may be unknown.
10
+ */
11
+ export function openV2Inbox({ reference, settings, binding }) {
12
+ const key = createHash('sha256').update(canonical({ binding, broker: settings.broker.url })).digest('hex');
13
+ const directory = privateDirectory(join(privateDirectory(settings.stateDirectory), 'opencode-v2-' + key));
14
+ const path = join(directory, 'binding.json');
15
+ let fd;
16
+ try {
17
+ fd = openSync(path, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600);
18
+ writeFileSync(fd, canonical({ version: 1, binding, broker: settings.broker.url, identity: reference.newSessionIdentity('opencode') }) + '\n');
19
+ fsyncSync(fd); closeSync(fd); fd = undefined;
20
+ const parent = openSync(directory, constants.O_RDONLY); try { fsyncSync(parent); } finally { closeSync(parent); }
21
+ } catch (error) { if (error.code !== 'EEXIST') throw error; }
22
+ finally { if (fd !== undefined) closeSync(fd); }
23
+ fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
24
+ let manifest;
25
+ try {
26
+ const stat = fstatSync(fd);
27
+ if (!stat.isFile() || stat.mode & 0o077 || stat.size > 8192 || process.getuid && stat.uid !== process.getuid()) throw new Error('v2_binding_unavailable');
28
+ manifest = JSON.parse(readFileSync(fd, 'utf8'));
29
+ } finally { closeSync(fd); }
30
+ if (manifest.version !== 1 || manifest.broker !== settings.broker.url || canonical(manifest.binding) !== canonical(binding)
31
+ || Object.keys(manifest).sort().join(',') !== 'binding,broker,identity,version') throw new Error('v2_binding_mismatch');
32
+ return new reference.Inbox({ directory, identity: manifest.identity });
33
+ }
@@ -0,0 +1,17 @@
1
+ import { DatabaseSync } from 'node:sqlite';
2
+ import { openSync, closeSync, lstatSync, constants } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { privateDirectory } from './config.mjs';
5
+
6
+ /** Cross-location/process ownership, released by the OS on crash. No stale reset. */
7
+ export async function withV2Lock(directory, name, action) {
8
+ if (!['host-anchor','setup'].includes(name)) throw new Error('v2_lock_invalid');
9
+ const path=join(privateDirectory(directory),`v2-${name}-owner.sqlite`);
10
+ try { const fd=openSync(path,constants.O_CREAT|constants.O_EXCL|constants.O_WRONLY|constants.O_NOFOLLOW,0o600);closeSync(fd); }
11
+ catch(error){if(error.code!=='EEXIST')throw error;}
12
+ const stat=lstatSync(path);
13
+ if(!stat.isFile()||stat.isSymbolicLink()||stat.mode&0o077||process.getuid&&stat.uid!==process.getuid())throw new Error('v2_lock_invalid');
14
+ let db;
15
+ try{db=new DatabaseSync(path);db.exec('PRAGMA busy_timeout=0; BEGIN EXCLUSIVE; CREATE TABLE IF NOT EXISTS owner(id INTEGER)');return await action();}
16
+ finally{db?.close();}
17
+ }