@matuto-ai/opencode-psst-connector 0.0.0-stage → 0.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.
- package/CHANGELOG.md +40 -0
- package/LICENSE +21 -0
- package/README.md +157 -2
- package/docs/active-conversation.md +68 -0
- package/docs/message-lifecycle.md +31 -0
- package/docs/migration-0.6.0.md +21 -0
- package/docs/native-automation.md +37 -0
- package/docs/presence-notices.md +21 -0
- package/docs/publishing.md +19 -0
- package/docs/reference-snapshot.sha256 +7 -0
- package/docs/setup-acceptance.md +96 -0
- package/docs/verification.md +39 -0
- package/package.json +42 -4
- package/plugin/index.mjs +18 -0
- package/plugin/tui.mjs +160 -0
- package/src/active-presence.mjs +97 -0
- package/src/approval-helper.mjs +11 -0
- package/src/config.mjs +50 -0
- package/src/enrollment.mjs +59 -0
- package/src/identity-recovery.mjs +94 -0
- package/src/installation.mjs +127 -0
- package/src/message-format.mjs +26 -0
- package/src/message-preview.mjs +29 -0
- package/src/native-delivery.mjs +169 -0
- package/src/native-inbox.mjs +37 -0
- package/src/native-incarnation.mjs +67 -0
- package/src/native-lock.mjs +17 -0
- package/src/native-metadata.mjs +15 -0
- package/src/native-plugin.mjs +335 -0
- package/src/native-setup.mjs +208 -0
- package/src/opencode-adapter.mjs +223 -0
- package/src/presence-notices.mjs +90 -0
- package/src/project-metadata.mjs +21 -0
- package/src/reference-pin.mjs +21 -0
- package/src/reference.mjs +20 -0
- package/src/setup-rpc.mjs +16 -0
- package/src/tool-schema.mjs +5 -0
- package/src/tool-scope.mjs +26 -0
- package/vendor/broker/LICENSE +21 -0
- package/vendor/broker/ORIGIN.md +12 -0
- package/vendor/broker/reference/client.mjs +439 -0
- package/vendor/broker/reference/errors.mjs +43 -0
- package/vendor/broker/reference/inbox.mjs +334 -0
- package/vendor/broker/reference/mcp-stdio.mjs +160 -0
- package/vendor/broker/reference/runtime.mjs +329 -0
- package/vendor/broker/reference/tools.mjs +88 -0
- package/vendor/broker/src/protocol.mjs +325 -0
- package/vendor/enrollment/LICENSE +21 -0
- package/vendor/enrollment/ORIGIN.md +20 -0
- package/vendor/enrollment/approval.mjs +181 -0
- package/vendor/enrollment/enrollment.mjs +137 -0
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import packageInfo from '../package.json' with { type: 'json' };
|
|
2
|
+
import { nativeMetadata } from './native-metadata.mjs';
|
|
3
|
+
import { createPresenceReader, presenceDirectory } from './active-presence.mjs';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { realpathSync } from 'node:fs';
|
|
6
|
+
import { canonical } from '../vendor/broker/src/protocol.mjs';
|
|
7
|
+
import { createOpenCodeAdapter, nativeToolDefinitions } from './opencode-adapter.mjs';
|
|
8
|
+
import { createNativeDelivery, nativeSessionBinding, HOST_VERSION } from './native-delivery.mjs';
|
|
9
|
+
import { openNativeIncarnation } from './native-incarnation.mjs';
|
|
10
|
+
import { openNativeInbox } from './native-inbox.mjs';
|
|
11
|
+
import { withNativeLock } from './native-lock.mjs';
|
|
12
|
+
import { readExistingConnection } from './enrollment.mjs';
|
|
13
|
+
import { SETUP_RPC } from './setup-rpc.mjs';
|
|
14
|
+
import { installationDirectory } from './installation.mjs';
|
|
15
|
+
import { createToolScope } from './tool-scope.mjs';
|
|
16
|
+
import { createNativeSetup, SETUP_TOOLS } from './native-setup.mjs';
|
|
17
|
+
import { recoverHostAnchor, readNativeBindings, assertExistingBinding } from './identity-recovery.mjs';
|
|
18
|
+
|
|
19
|
+
const validID = value => typeof value === 'string' && /^[A-Za-z0-9_.:-]{1,256}$/.test(value);
|
|
20
|
+
const display = value => JSON.stringify(value).replace(/[\u007f-\u009f\u2028\u2029\u202a-\u202e\u2066-\u2069]/gu,
|
|
21
|
+
char => `\\u${char.codePointAt(0).toString(16).padStart(4, '0')}`);
|
|
22
|
+
|
|
23
|
+
/** Current native OpenCode setup. Never upgrades or mutates the host.
|
|
24
|
+
* No receive/ack fallback is registered: incoming delivery is native automatic.
|
|
25
|
+
*/
|
|
26
|
+
export async function setupNativePlugin({ ctx, reference, tool, loadSettings, clientFactory,
|
|
27
|
+
setupOptions = {}, metadataOptions = {}, setupEnabled = true, deliveryFactory = createNativeDelivery, adapterFactory = createOpenCodeAdapter, presenceFactory = createPresenceReader, onDiagnostic = () => {} }) {
|
|
28
|
+
if (ctx?.app?.version !== HOST_VERSION || ['get', 'synthetic', 'context', 'hook'].some(k => typeof ctx?.session?.[k] !== 'function')
|
|
29
|
+
|| typeof ctx?.storage?.get !== 'function' || typeof ctx?.storage?.set !== 'function' || typeof ctx?.storage?.scan !== 'function'
|
|
30
|
+
|| typeof ctx?.tool?.transform !== 'function' || typeof ctx?.event?.subscribe !== 'function') throw new Error('psst_host_capability_unavailable');
|
|
31
|
+
const directory = realpathSync(ctx.location.directory);
|
|
32
|
+
let authorizationExpired = false;
|
|
33
|
+
const readCurrentSettings = async options => {
|
|
34
|
+
try { const settings = await loadSettings(options); authorizationExpired = settings?.authorizationExpired === true; return settings; }
|
|
35
|
+
catch (error) { if (error.code === 'credential_expired') authorizationExpired = true; throw error; }
|
|
36
|
+
};
|
|
37
|
+
const hostID = await withNativeLock(setupOptions.directory ?? installationDirectory(), 'host-anchor', async () => {
|
|
38
|
+
const settings = await readCurrentSettings({ identityRecovery: setupEnabled });
|
|
39
|
+
authorizationExpired = settings?.authorizationExpired === true;
|
|
40
|
+
return recoverHostAnchor({ storage: ctx.storage, session: ctx.session, settings, directory, workspaceID: ctx.location.workspaceID });
|
|
41
|
+
});
|
|
42
|
+
const registryPrefix = 'psst-v2-session:' + createHash('sha256').update(canonical({ directory, workspaceID: ctx.location.workspaceID ?? null })).digest('hex') + ':';
|
|
43
|
+
if (!validID(hostID)) throw new Error('psst_host_binding_unavailable');
|
|
44
|
+
const sessions = new Map(), preparing = new Map(), blocked = new Set(), authorizationBlocked = new Map(), archived = new Set(), controller = new AbortController(), registrations = [], toolScope = createToolScope();
|
|
45
|
+
let disposed = false, closing, events, setup, presenceTimer, synchronizing;
|
|
46
|
+
const presence = presenceFactory({ directory: presenceDirectory(setupOptions.directory ?? installationDirectory(), ctx.location, { create: true }) });
|
|
47
|
+
const selected = sessionID => { try { return presence.selected().has(sessionID); } catch { return false; } };
|
|
48
|
+
const diagnostic = code => { try { onDiagnostic({ code }); } catch {} };
|
|
49
|
+
async function getInfo(sessionID, signal = controller.signal) {
|
|
50
|
+
if (!validID(sessionID) || signal.aborted || disposed) throw new Error('native_session_mismatch');
|
|
51
|
+
const bounded = AbortSignal.any([signal, AbortSignal.timeout(2000)]);
|
|
52
|
+
let listener;
|
|
53
|
+
const info = await Promise.race([
|
|
54
|
+
ctx.session.get({ sessionID }, { signal: bounded }),
|
|
55
|
+
new Promise((_, reject) => { listener = () => reject(new Error('native_session_unavailable'));
|
|
56
|
+
if (bounded.aborted) listener(); else bounded.addEventListener('abort', listener, { once: true }); }),
|
|
57
|
+
]).finally(() => bounded.removeEventListener('abort', listener));
|
|
58
|
+
if (bounded.aborted || disposed || info?.id !== sessionID || info.parentID || info.time?.archived
|
|
59
|
+
|| realpathSync(info.location.directory) !== directory || info.location.workspaceID !== ctx.location.workspaceID) throw new Error('native_session_mismatch');
|
|
60
|
+
return info;
|
|
61
|
+
}
|
|
62
|
+
async function close(sessionID, state = sessions.get(sessionID)) {
|
|
63
|
+
if (!state) return;
|
|
64
|
+
if (state.closing) return state.closing;
|
|
65
|
+
state.controller.abort();
|
|
66
|
+
state.closing = (async () => {
|
|
67
|
+
await state.starting?.catch(() => {});
|
|
68
|
+
await state.delivery?.stop();
|
|
69
|
+
await state.adapter?.stop();
|
|
70
|
+
if (!state.adapter) state.inbox?.close();
|
|
71
|
+
state.incarnation?.close();
|
|
72
|
+
if (sessions.get(sessionID) === state) sessions.delete(sessionID);
|
|
73
|
+
if (preparing.get(sessionID) === state) preparing.delete(sessionID);
|
|
74
|
+
})();
|
|
75
|
+
return state.closing;
|
|
76
|
+
}
|
|
77
|
+
async function connect(sessionID, { candidateSettings } = {}) {
|
|
78
|
+
const target = candidateSettings ? preparing : sessions;
|
|
79
|
+
if (disposed || blocked.has(sessionID) || !candidateSettings && authorizationBlocked.has(sessionID) || archived.has(sessionID)) throw new Error('native_plugin_closed');
|
|
80
|
+
if (!selected(sessionID)) throw new Error('native_session_not_selected');
|
|
81
|
+
let state = target.get(sessionID);
|
|
82
|
+
if (state) { await state.starting; if (state.closing) throw new Error('native_session_closing'); return state; }
|
|
83
|
+
if (sessions.size >= 64) throw new Error('native_session_limit');
|
|
84
|
+
state = { controller: new AbortController(), lifetime: Symbol('native host session') };
|
|
85
|
+
target.set(sessionID, state);
|
|
86
|
+
state.starting = (async () => {
|
|
87
|
+
const info = await getInfo(sessionID, state.controller.signal);
|
|
88
|
+
const nativeBinding = nativeSessionBinding(info, hostID);
|
|
89
|
+
const settings = candidateSettings ?? await readCurrentSettings();
|
|
90
|
+
state.settings = settings;
|
|
91
|
+
if (!settings || disposed || blocked.has(sessionID) || state.controller.signal.aborted) throw new Error('psst_connect_required');
|
|
92
|
+
const valid = async () => {
|
|
93
|
+
if (settings.enrollment && Date.parse(settings.enrollment.expiresAt) <= Date.now()) return null;
|
|
94
|
+
if (disposed || !selected(sessionID) || blocked.has(sessionID) || state.controller.signal.aborted || sessions.get(sessionID) !== state && preparing.get(sessionID) !== state
|
|
95
|
+
|| settings.enrollment && Date.parse(settings.enrollment.expiresAt) <= Date.now()) return null;
|
|
96
|
+
try {
|
|
97
|
+
const now = await getInfo(sessionID, state.controller.signal);
|
|
98
|
+
return selected(sessionID) && !state.controller.signal.aborted && canonical(nativeSessionBinding(now, hostID)) === canonical(nativeBinding) ? now : null;
|
|
99
|
+
} catch { return null; }
|
|
100
|
+
};
|
|
101
|
+
assertExistingBinding(settings, nativeBinding);
|
|
102
|
+
state.incarnation = settings.broker.registrationVersion === 2 ? openNativeIncarnation({ reference, settings, binding: nativeBinding }) : null;
|
|
103
|
+
state.inbox = state.incarnation?.inbox ?? openNativeInbox({ reference, settings, binding: nativeBinding });
|
|
104
|
+
await ctx.storage.set(registryPrefix + sessionID, { sessionID, binding: nativeBinding, enabled: true });
|
|
105
|
+
if (blocked.has(sessionID)) await ctx.storage.set(registryPrefix + sessionID, { sessionID, enabled: false });
|
|
106
|
+
if (!await valid()) throw new Error('native_session_mismatch');
|
|
107
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
108
|
+
state.client = clientFactory ? clientFactory(state.inbox) : new reference.BrokerClient({ ...settings.broker, inbox: state.inbox });
|
|
109
|
+
const binding = { sessionID, directory, worktree: directory, lifetime: state.lifetime, signal: state.controller.signal };
|
|
110
|
+
state.adapter = adapterFactory({ reference, tool, client: state.client, binding,
|
|
111
|
+
currentBinding: async () => await valid() ? binding : null, closeSessionOnStop: false,
|
|
112
|
+
observeMetadata: async () => { const current = await valid(); if (!current) return null; const metadata = nativeMetadata(current, { ...settings, ...metadataOptions });
|
|
113
|
+
return canonical(metadata) === canonical(state.inbox.getState('metadata')) ? null : metadata; },
|
|
114
|
+
operationContext: context => ({ operationId: canonical({ hostID, sessionID, messageID: context.messageID, callID: context.callID }), operationStartedAt: context.operationStartedAt }),
|
|
115
|
+
onDiagnostic: entry => {
|
|
116
|
+
diagnostic(entry.code);
|
|
117
|
+
if (entry.code === 'native_binding_unavailable' || entry.code === 'session_expired' && state.delivery) queueMicrotask(() => void close(sessionID, state).catch(() => {}));
|
|
118
|
+
if (['unauthenticated', 'forbidden', 'registration_upgrade_required', 'retry_window_elapsed'].includes(entry.code)) { if (sessions.get(sessionID) === state) authorizationBlocked.set(sessionID, entry.code); queueMicrotask(() => void close(sessionID, state).catch(() => {})); }
|
|
119
|
+
} });
|
|
120
|
+
try { await state.adapter.start(nativeMetadata(info, { ...settings, ...metadataOptions })); break; }
|
|
121
|
+
catch (error) {
|
|
122
|
+
if (attempt !== 0 || error.code !== 'session_expired' || !state.incarnation || !state.client.expiredIdentityConfirmed) throw error;
|
|
123
|
+
await state.adapter.stop();
|
|
124
|
+
if (!await valid()) throw new Error('native_session_mismatch');
|
|
125
|
+
state.inbox = await state.incarnation.replace({ client: state.client, verifySession: async () => !!await valid(), signal: state.controller.signal });
|
|
126
|
+
state.adapter = undefined;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (!await valid()) throw new Error('native_session_mismatch');
|
|
130
|
+
state.delivery = deliveryFactory({ session: ctx.session, client: state.client, inbox: state.inbox, binding: nativeBinding,
|
|
131
|
+
signal: state.controller.signal, verify: valid, available: () => sessions.get(sessionID) === state && selected(sessionID), onDiagnostic: entry => { state.deliveryStatus = entry.code; diagnostic(entry.code); } });
|
|
132
|
+
if (!candidateSettings) state.delivery.start();
|
|
133
|
+
return state;
|
|
134
|
+
})();
|
|
135
|
+
try { return await state.starting; }
|
|
136
|
+
catch (error) {
|
|
137
|
+
// Storage uncertainty is terminal for automatic restore in this plugin
|
|
138
|
+
// lifetime. A deliberate restart re-reads the durable pointer first.
|
|
139
|
+
if (!candidateSettings && error.code === 'native_incarnation_uncertain') blocked.add(sessionID);
|
|
140
|
+
state.controller.abort(); await state.delivery?.stop(); await state.adapter?.stop();
|
|
141
|
+
if (!state.adapter) state.inbox?.close();
|
|
142
|
+
state.incarnation?.close();
|
|
143
|
+
if (sessions.get(sessionID) === state) sessions.delete(sessionID);
|
|
144
|
+
if (preparing.get(sessionID) === state) preparing.delete(sessionID);
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
// Prepare the approved candidate without native delivery while the previous
|
|
149
|
+
// authorization stays live. Only the exact pending-flow CAS promotes it. Old
|
|
150
|
+
// credential-bound journals remain intact; explicit replacement never adopts
|
|
151
|
+
// an inbox belonging to another authority.
|
|
152
|
+
async function activate({ sessionID, connectionFile, commit, signal }) {
|
|
153
|
+
const settings = await readExistingConnection(connectionFile);
|
|
154
|
+
const previous = sessions.get(sessionID);
|
|
155
|
+
await previous?.starting;
|
|
156
|
+
const same = previous && canonical({ broker: previous.settings.broker, stateDirectory: previous.settings.stateDirectory })
|
|
157
|
+
=== canonical({ broker: settings.broker, stateDirectory: settings.stateDirectory });
|
|
158
|
+
if (same) { if (signal.aborted) throw new Error('psst_setup_cancelled'); await commit(); return previous; }
|
|
159
|
+
let candidate;
|
|
160
|
+
const cancel = () => preparing.get(sessionID)?.controller.abort();
|
|
161
|
+
signal.addEventListener('abort', cancel, { once: true });
|
|
162
|
+
try {
|
|
163
|
+
if (signal.aborted) throw new Error('psst_setup_cancelled');
|
|
164
|
+
candidate = await connect(sessionID, { candidateSettings: settings });
|
|
165
|
+
if (signal.aborted || candidate.controller.signal.aborted || !selected(sessionID)) throw new Error('psst_setup_cancelled');
|
|
166
|
+
await commit();
|
|
167
|
+
// No await between committed selection and map promotion. Starting native
|
|
168
|
+
// delivery is synchronous and has no submission side effect until its tick.
|
|
169
|
+
preparing.delete(sessionID); sessions.set(sessionID, candidate); authorizationBlocked.delete(sessionID); authorizationExpired = false;
|
|
170
|
+
candidate.delivery.start();
|
|
171
|
+
} catch (error) {
|
|
172
|
+
if (candidate && sessions.get(sessionID) !== candidate) await close(sessionID, candidate);
|
|
173
|
+
throw error;
|
|
174
|
+
} finally { signal.removeEventListener('abort', cancel); }
|
|
175
|
+
if (previous) await close(sessionID, previous);
|
|
176
|
+
return candidate;
|
|
177
|
+
}
|
|
178
|
+
const catalog = nativeToolDefinitions(reference.TOOL_DEFINITIONS).filter(def => def.name !== 'broker_receive');
|
|
179
|
+
function dispose() {
|
|
180
|
+
if (closing) return closing;
|
|
181
|
+
// Every caller joins the same drain, including shutdown triggered by the
|
|
182
|
+
// event subscription. Do not release an owner while its calls still run.
|
|
183
|
+
closing = Promise.resolve().then(async () => {
|
|
184
|
+
await setup?.stop();
|
|
185
|
+
await toolScope.close();
|
|
186
|
+
await Promise.all([...sessions, ...preparing].map(([id, state]) => close(id, state)));
|
|
187
|
+
await Promise.all(registrations.map(item => item.dispose()));
|
|
188
|
+
await events?.catch(() => {});
|
|
189
|
+
});
|
|
190
|
+
disposed = true; clearInterval(presenceTimer); controller.abort();
|
|
191
|
+
for (const state of [...sessions.values(), ...preparing.values()]) state.controller.abort();
|
|
192
|
+
return closing;
|
|
193
|
+
}
|
|
194
|
+
const status = sessionID => {
|
|
195
|
+
const state = sessions.get(sessionID);
|
|
196
|
+
const expired = state?.settings ? !!state.settings.enrollment && Date.parse(state.settings.enrollment.expiresAt) <= Date.now() : authorizationExpired;
|
|
197
|
+
const rejected = ['unauthenticated', 'forbidden'].includes(authorizationBlocked.get(sessionID));
|
|
198
|
+
const authorization = expired ? 'expired' : rejected ? 'rejected' : authorizationBlocked.has(sessionID) ? 'blocked' : state?.delivery ? 'active' : 'unknown';
|
|
199
|
+
return { connector_version: packageInfo.version, host_version: HOST_VERSION, mode: 'native_automatic',
|
|
200
|
+
connected: !disposed && !state?.controller.signal.aborted && !!state?.delivery && selected(sessionID) && authorization === 'active',
|
|
201
|
+
availability: selected(sessionID) ? 'selected' : 'inactive', authorization_state: authorization,
|
|
202
|
+
client_id: state?.client?.inbox?.identity?.client_id ?? null,
|
|
203
|
+
last_delivery_status: state?.deliveryStatus ?? null, receipt_semantics: 'durable_native_admission_not_model_read' };
|
|
204
|
+
};
|
|
205
|
+
if (setupEnabled) setup = createNativeSetup({ storage: ctx.storage, verify: async id => { try { if (!selected(id)) return null; const info = await getInfo(id); return selected(id) ? info : null; } catch { return null; } }, connect, activate, status, signal: controller.signal, ...setupOptions });
|
|
206
|
+
try {
|
|
207
|
+
if (setup && ctx.rpc?.register) {
|
|
208
|
+
const invoke = async (name, input, rpcContext) => {
|
|
209
|
+
if (!selected(input.sessionID)) throw new Error('native_session_not_selected');
|
|
210
|
+
try { return await setup.invoke(name, name === 'broker_setup' ? { broker_url: input.broker_url }
|
|
211
|
+
: name === 'broker_setup_status' && input.broker_url ? { broker_url: input.broker_url } : {}, {
|
|
212
|
+
sessionID: input.sessionID, signal: rpcContext.signal,
|
|
213
|
+
confirmedReplacement: input.replacement_confirmed === true,
|
|
214
|
+
replacementRequestId: input.replacement_request_id,
|
|
215
|
+
}); } catch { return { state: 'setup_blocked', ...status(input.sessionID) }; }
|
|
216
|
+
};
|
|
217
|
+
registrations.push(await ctx.rpc.register(SETUP_RPC, {
|
|
218
|
+
status: (input, context) => invoke('broker_setup_status', input, context),
|
|
219
|
+
setup: (input, context) => invoke('broker_setup', input, context),
|
|
220
|
+
cancel: (input, context) => invoke('broker_cancel_setup', input, context),
|
|
221
|
+
}));
|
|
222
|
+
}
|
|
223
|
+
// Read all known tombstones before publishing hooks/tools or starting any
|
|
224
|
+
// manifest restoration. A disabled session must never be resurrected.
|
|
225
|
+
let cursor, scanned = 0;
|
|
226
|
+
do {
|
|
227
|
+
const saved = await ctx.storage.scan({ prefix: registryPrefix, limit: 64, ...(cursor ? { after: cursor } : {}) });
|
|
228
|
+
for (const entry of saved.entries) {
|
|
229
|
+
if (++scanned > 256) throw new Error('native_registry_scan_limit');
|
|
230
|
+
const record = entry.value;
|
|
231
|
+
if (record?.enabled === false && validID(record.sessionID)) {
|
|
232
|
+
if (record.reason === 'archived') archived.add(record.sessionID); else blocked.add(record.sessionID);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
cursor = saved.next;
|
|
236
|
+
} while (cursor && !disposed);
|
|
237
|
+
registrations.push(await ctx.tool.transform(editor => {
|
|
238
|
+
// Preflight the entire catalog before mutating the host editor.
|
|
239
|
+
for (const definition of [...(setup ? SETUP_TOOLS : []), ...catalog]) if (editor.get(definition.name)) throw new Error('psst_tool_conflict');
|
|
240
|
+
for (const definition of setup ? SETUP_TOOLS : []) {
|
|
241
|
+
if (editor.get(definition.name)) throw new Error('psst_tool_conflict');
|
|
242
|
+
editor.add({ ...definition, async execute(args, context) {
|
|
243
|
+
try { return { content: display(await setup.invoke(definition.name, args, context)) }; }
|
|
244
|
+
catch { return { content: display({ state: 'setup_blocked', next_step: 'Check setup status; do not paste credentials or automatically replace the request.' }) }; }
|
|
245
|
+
} });
|
|
246
|
+
}
|
|
247
|
+
for (const definition of catalog) {
|
|
248
|
+
if (editor.get(definition.name)) throw new Error('psst_tool_conflict');
|
|
249
|
+
editor.add({ name: definition.name, description: definition.description, input: definition.inputSchema,
|
|
250
|
+
// CodeMode children share the outer execute ID. Outgoing effects must
|
|
251
|
+
// use direct native calls, which have durable individual IDs/times.
|
|
252
|
+
...(['broker_send', 'broker_resend'].includes(definition.name) ? { options: { codemode: false } } : {}),
|
|
253
|
+
async execute(args, context) {
|
|
254
|
+
return toolScope.run(async operationSignal => {
|
|
255
|
+
try {
|
|
256
|
+
if (!(context?.signal instanceof AbortSignal) || context.signal.aborted || !validID(context.messageID) || !validID(context.id)) throw new Error('native_session_mismatch');
|
|
257
|
+
const state = await connect(context.sessionID);
|
|
258
|
+
let operationStartedAt;
|
|
259
|
+
if (['broker_send', 'broker_resend'].includes(definition.name)) {
|
|
260
|
+
const bounded = AbortSignal.any([context.signal, operationSignal, controller.signal, AbortSignal.timeout(2000)]);
|
|
261
|
+
let listener;
|
|
262
|
+
const messages = await Promise.race([ctx.session.context({ sessionID: context.sessionID }, { signal: bounded }),
|
|
263
|
+
new Promise((_, reject) => { listener = () => reject(new Error('native_context_unavailable')); if (bounded.aborted) listener(); else bounded.addEventListener('abort', listener, { once: true }); })
|
|
264
|
+
]).finally(() => bounded.removeEventListener('abort', listener));
|
|
265
|
+
const message = messages.find(item => item.id === context.messageID && item.type === 'assistant');
|
|
266
|
+
const call = message?.content?.find(item => item.type === 'tool' && item.id === context.id && item.name === definition.name);
|
|
267
|
+
operationStartedAt = call?.time?.created;
|
|
268
|
+
if (!Number.isSafeInteger(operationStartedAt) || operationStartedAt <= 0) throw new Error('native_original_tool_timestamp_unavailable');
|
|
269
|
+
}
|
|
270
|
+
const content = await state.adapter.tools[definition.name].execute(args, { sessionID: context.sessionID,
|
|
271
|
+
messageID: context.messageID, callID: context.id, ...(operationStartedAt ? { operationStartedAt } : {}), directory, worktree: directory, abort: AbortSignal.any([context.signal, operationSignal, controller.signal]) });
|
|
272
|
+
return { content };
|
|
273
|
+
} catch { return { content: display({ error: { code: 'session_mismatch', effect: 'unknown' } }) }; }
|
|
274
|
+
}, context?.signal).catch(() => ({ content: display({ error: { code: 'session_mismatch', effect: 'unknown' } }) }));
|
|
275
|
+
} });
|
|
276
|
+
}
|
|
277
|
+
}));
|
|
278
|
+
// Prompt observation only, no mutation of human draft, selection or prompt.
|
|
279
|
+
registrations.push(await ctx.session.hook('prompt', event => { void connect(event.sessionID).catch(() => diagnostic('native_connection_unavailable')); }));
|
|
280
|
+
events = (async () => {
|
|
281
|
+
for await (const event of ctx.event.subscribe({ signal: controller.signal })) {
|
|
282
|
+
if (disposed) break;
|
|
283
|
+
if (event.type === 'location.shutdown' && event.location?.directory === directory) { void dispose(); break; }
|
|
284
|
+
const sessionID = event.data?.sessionID;
|
|
285
|
+
setup?.event(event);
|
|
286
|
+
const state = sessions.get(sessionID);
|
|
287
|
+
state?.delivery?.event(event);
|
|
288
|
+
if (['session.deleted', 'session.archived', 'session.moved'].includes(event.type)) {
|
|
289
|
+
if (event.type === 'session.archived') archived.add(sessionID); else blocked.add(sessionID); state?.controller.abort();
|
|
290
|
+
if (validID(sessionID)) await ctx.storage.set(registryPrefix + sessionID, { sessionID, enabled: false, reason: event.type === 'session.archived' ? 'archived' : 'disabled' });
|
|
291
|
+
await Promise.all([close(sessionID), close(sessionID, preparing.get(sessionID))]);
|
|
292
|
+
}
|
|
293
|
+
if (event.type === 'session.unarchived' && archived.has(sessionID) && !blocked.has(sessionID)) {
|
|
294
|
+
archived.delete(sessionID);
|
|
295
|
+
await ctx.storage.set(registryPrefix + sessionID, { sessionID, enabled: true });
|
|
296
|
+
}
|
|
297
|
+
if (event.type === 'session.created') void connect(sessionID).catch(() => diagnostic('native_connection_unavailable'));
|
|
298
|
+
}
|
|
299
|
+
// A clean EOF is still loss of cancellation/lifecycle observation. Polling
|
|
300
|
+
// native state cannot replace the missed in-flight event boundaries.
|
|
301
|
+
if (!disposed) throw new Error('native_event_stream_unavailable');
|
|
302
|
+
})().catch(() => { if (!disposed) { diagnostic('native_event_stream_unavailable'); void dispose().catch(() => diagnostic('native_shutdown_incomplete')); } });
|
|
303
|
+
// Selection never creates a binding. It only restores an already persisted,
|
|
304
|
+
// independently checked native identity. New sessions connect through their
|
|
305
|
+
// verified native prompt/tool/setup context, not through a lease file.
|
|
306
|
+
const synchronize = async () => {
|
|
307
|
+
if (disposed || synchronizing) return;
|
|
308
|
+
synchronizing = (async () => {
|
|
309
|
+
let active; try { active = presence.selected(); } catch { active = new Set(); diagnostic('native_selection_unavailable'); }
|
|
310
|
+
await Promise.all([...sessions.keys()].filter(id => !active.has(id)).map(id => close(id)));
|
|
311
|
+
if (disposed) return;
|
|
312
|
+
// Candidate recovery is independent of a valid working installation.
|
|
313
|
+
// A corrupt pending flow cannot suppress restoration of its old mailbox.
|
|
314
|
+
try { await setup?.resume(); } catch { diagnostic('native_setup_restore_unavailable'); }
|
|
315
|
+
const settings = await readCurrentSettings();
|
|
316
|
+
for (const record of readNativeBindings(settings)) {
|
|
317
|
+
const b = record.binding;
|
|
318
|
+
if (active.has(b.sessionID) && b.hostID === hostID && b.location.directory === directory
|
|
319
|
+
&& b.location.workspaceID === ctx.location.workspaceID && !blocked.has(b.sessionID)) {
|
|
320
|
+
if (archived.has(b.sessionID)) {
|
|
321
|
+
try { await getInfo(b.sessionID); archived.delete(b.sessionID); } catch { continue; }
|
|
322
|
+
}
|
|
323
|
+
await connect(b.sessionID).catch(() => diagnostic('native_restore_unavailable'));
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
})().catch(() => diagnostic('native_selection_unavailable'));
|
|
327
|
+
try { await synchronizing; } finally { synchronizing = undefined; }
|
|
328
|
+
};
|
|
329
|
+
await synchronize();
|
|
330
|
+
if (disposed) { await dispose(); throw new Error('native_event_stream_unavailable'); }
|
|
331
|
+
presenceTimer = setInterval(() => void synchronize(), 500); presenceTimer.unref?.();
|
|
332
|
+
void setup?.resume().catch(() => diagnostic('native_setup_restore_unavailable'));
|
|
333
|
+
return { dispose, connect, status, activate };
|
|
334
|
+
} catch (error) { await dispose(); throw error; }
|
|
335
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { canonical } from '../vendor/broker/src/protocol.mjs';
|
|
2
|
+
import { loadApprovalHelper } from './approval-helper.mjs';
|
|
3
|
+
import { installationDirectory, readSelection, readPending, savePending, updatePending, selectInstallation } from './installation.mjs';
|
|
4
|
+
import { withNativeLock } from './native-lock.mjs';
|
|
5
|
+
import { createToolScope } from './tool-scope.mjs';
|
|
6
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
7
|
+
|
|
8
|
+
const publicState = value => ({ state: value?.state ?? 'setup_needed',
|
|
9
|
+
...(value?.requestId ? { request_id: value.requestId } : {}),
|
|
10
|
+
...(value?.approvalUrl ? { approval_url: value.approvalUrl } : {}),
|
|
11
|
+
...(value?.requestExpiresAt ? { request_expires_at: value.requestExpiresAt } : {}) });
|
|
12
|
+
export const SETUP_TOOLS = [
|
|
13
|
+
{ name: 'broker_setup', description: 'Explicit user-requested URL-only Psst setup. Provide only the HTTPS broker URL. Creates or resumes one private request for human approval. Never call on startup. Replacing an authorization requires confirmation in the native /psst-setup dialog.',
|
|
14
|
+
input: { type: 'object', properties: { broker_url: { type: 'string', maxLength: 2048 } }, required: ['broker_url'], additionalProperties: false } },
|
|
15
|
+
{ name: 'broker_setup_status', description: 'Read public Psst setup/connection status. Does not create approval requests or disclose credentials/private files.',
|
|
16
|
+
input: { type: 'object', properties: {}, required: [], additionalProperties: false } },
|
|
17
|
+
{ name: 'broker_cancel_setup', description: 'Pause this session\'s local pending Psst setup. Preserves the prior authorization and durable request; does not revoke credentials.',
|
|
18
|
+
input: { type: 'object', properties: {}, required: [], additionalProperties: false } },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
/** One explicit URL flow per installation. Recovery is read/GET-only. The private
|
|
22
|
+
* pending record retains the exact native owner across plugin namespace reloads.
|
|
23
|
+
* Candidate attachment happens before the selection CAS, while the old session
|
|
24
|
+
* remains working; neither a model tool nor background polling confirms replacement.
|
|
25
|
+
*/
|
|
26
|
+
export function createNativeSetup({ storage, verify, connect, activate, status, signal, directory = installationDirectory(),
|
|
27
|
+
loadHelper = loadApprovalHelper, select = selectInstallation, pollMs = 2000 }) {
|
|
28
|
+
if (!Number.isSafeInteger(pollMs) || pollMs < 10) throw new Error('setup_interval_invalid');
|
|
29
|
+
const scope = createToolScope(), lifetime = new AbortController();
|
|
30
|
+
let running, owner, blockedFlow, ownerController = new AbortController(), beginning = false, beginningController, beginningSessionID, last = { state: 'setup_needed' }, stopped = false;
|
|
31
|
+
const alive = () => !stopped && !signal.aborted && !lifetime.signal.aborted;
|
|
32
|
+
const binding = info => canonical({ id: info.id, projectID: info.projectID, location: info.location, created: info.time.created });
|
|
33
|
+
const bound = async id => {
|
|
34
|
+
if (!alive()) return false;
|
|
35
|
+
const info = await verify(id);
|
|
36
|
+
return !!info && (!owner?.binding || owner.sessionID !== id || binding(info) === owner.binding);
|
|
37
|
+
};
|
|
38
|
+
const savedOwner = async pending => pending?.owner ? { ...pending.owner, flowId: pending.flowId } : await storage.get('psst-v2-setup-owner');
|
|
39
|
+
async function pauseOwned() {
|
|
40
|
+
if (owner) updatePending({ directory, flowId: owner.flowId, paused: true });
|
|
41
|
+
}
|
|
42
|
+
function block(pending) {
|
|
43
|
+
blockedFlow = pending.flowId;
|
|
44
|
+
// Keep the in-memory fence even when storage itself is the fatal failure.
|
|
45
|
+
try { updatePending({ directory, flowId: pending.flowId, paused: true }); } catch {}
|
|
46
|
+
last = { ...last, state: 'setup_blocked' };
|
|
47
|
+
}
|
|
48
|
+
function remember(result, pending) {
|
|
49
|
+
last = publicState(result);
|
|
50
|
+
if (pending?.paused && result?.state === 'pending') last = { ...last, state: 'paused', request_state: 'pending' };
|
|
51
|
+
if (pending?.paused && result?.state === 'approved' && readSelection(directory)?.connectionFile !== result.connectionFile)
|
|
52
|
+
last = { ...last, state: 'paused', request_state: 'approved' };
|
|
53
|
+
return last;
|
|
54
|
+
}
|
|
55
|
+
async function finish(result, pending, sessionID, operationSignal, flowController = ownerController) {
|
|
56
|
+
if (!await bound(sessionID) || flowController.signal.aborted) return;
|
|
57
|
+
if (operationSignal?.aborted) { await pauseOwned(); return; }
|
|
58
|
+
const current = readPending(directory);
|
|
59
|
+
if (!current || current.paused || current.flowId !== pending.flowId) return;
|
|
60
|
+
if (result.requestId !== pending.requestId) throw new Error('approval_request_mismatch');
|
|
61
|
+
remember(result, current);
|
|
62
|
+
if (result.state === 'approved') {
|
|
63
|
+
const activationSignal = AbortSignal.any([signal, lifetime.signal, flowController.signal, ...(operationSignal ? [operationSignal] : [])]);
|
|
64
|
+
const commit = () => select({ directory, connectionFile: result.connectionFile, expectedRevision: pending.expectedRevision,
|
|
65
|
+
pendingFlowId: pending.flowId, verify: () => bound(sessionID), signal: activationSignal });
|
|
66
|
+
try {
|
|
67
|
+
if (activate) await activate({ sessionID, connectionFile: result.connectionFile, commit, signal: activationSignal });
|
|
68
|
+
else { await commit(); if (!activationSignal.aborted && await bound(sessionID)) await connect(sessionID); }
|
|
69
|
+
} finally { if (activationSignal.aborted) await pauseOwned(); }
|
|
70
|
+
} else if (result.state !== 'pending') updatePending({ directory, flowId: pending.flowId, paused: true });
|
|
71
|
+
}
|
|
72
|
+
function watch(pending, sessionID) {
|
|
73
|
+
if (running) return;
|
|
74
|
+
const flowController = ownerController;
|
|
75
|
+
const task = (async () => {
|
|
76
|
+
const helper = await loadHelper();
|
|
77
|
+
while (!flowController.signal.aborted && await bound(sessionID)) {
|
|
78
|
+
const current = readPending(directory);
|
|
79
|
+
if (!current || current.flowId !== pending.flowId || current.paused) return;
|
|
80
|
+
let waitMs = pollMs;
|
|
81
|
+
try {
|
|
82
|
+
const result = await helper.pollApproval({ directory, brokerUrl: current.brokerUrl, clientType: 'opencode' });
|
|
83
|
+
if (flowController.signal.aborted || !await bound(sessionID)) return;
|
|
84
|
+
await finish(result, current, sessionID, undefined, flowController);
|
|
85
|
+
if (result.state !== 'pending') return;
|
|
86
|
+
} catch (error) {
|
|
87
|
+
if (flowController.signal.aborted) return;
|
|
88
|
+
if (!error.retryable) { block(pending); return; }
|
|
89
|
+
waitMs = Math.min(300000, Math.max(pollMs, error.retryAfterMs || pollMs));
|
|
90
|
+
}
|
|
91
|
+
await delay(waitMs, undefined, { signal: AbortSignal.any([signal, lifetime.signal, flowController.signal]) }).catch(() => {});
|
|
92
|
+
}
|
|
93
|
+
})().catch(() => { if (!flowController.signal.aborted) block(pending); }).finally(() => { if (running === task) running = null; });
|
|
94
|
+
running = task;
|
|
95
|
+
}
|
|
96
|
+
function origin(value) {
|
|
97
|
+
const url = new URL(value);
|
|
98
|
+
if (typeof value !== 'string' || value.length > 2048 || /[\x00-\x20\x7f]/.test(value)
|
|
99
|
+
|| url.protocol !== 'https:' || url.username || url.password || url.search || url.hash || url.pathname !== '/') throw new Error('https_origin_required');
|
|
100
|
+
return url;
|
|
101
|
+
}
|
|
102
|
+
function requiresReplacement(previous, pending, url) {
|
|
103
|
+
return !!previous && previous.state !== 'pending' && !(previous.state === 'approved'
|
|
104
|
+
&& pending?.brokerUrl === url && pending.requestId === previous.requestId
|
|
105
|
+
&& readSelection(directory)?.connectionFile !== previous.connectionFile);
|
|
106
|
+
}
|
|
107
|
+
async function invoke(name, args, context) {
|
|
108
|
+
return scope.run(async operationSignal => {
|
|
109
|
+
if (!(context?.signal instanceof AbortSignal) || context.signal.aborted || !await bound(context.sessionID)) throw new Error('session_mismatch');
|
|
110
|
+
if (name === 'broker_cancel_setup') {
|
|
111
|
+
if (beginningSessionID === context.sessionID) { beginningController.abort(); last = { ...last, state: 'paused' }; }
|
|
112
|
+
if (owner?.sessionID === context.sessionID) { ownerController.abort(); await pauseOwned(); last = { ...last, state: 'paused' }; }
|
|
113
|
+
return { ...last, ...status(context.sessionID) };
|
|
114
|
+
}
|
|
115
|
+
if (name === 'broker_setup_status') {
|
|
116
|
+
if (args?.broker_url !== undefined) {
|
|
117
|
+
const url = origin(args.broker_url).href, helper = await loadHelper();
|
|
118
|
+
const previous = helper.readApprovalState({ directory, brokerUrl: url, clientType: 'opencode' });
|
|
119
|
+
return { ...publicState(previous), ...status(context.sessionID),
|
|
120
|
+
replacement_confirmation_required: requiresReplacement(previous, readPending(directory), url) };
|
|
121
|
+
}
|
|
122
|
+
return { ...last, ...status(context.sessionID) };
|
|
123
|
+
}
|
|
124
|
+
if (name !== 'broker_setup' || !args || Object.keys(args).join(',') !== 'broker_url') throw new Error('invalid_configuration');
|
|
125
|
+
const url = origin(args.broker_url);
|
|
126
|
+
const ownedPending = readPending(directory);
|
|
127
|
+
if (beginning || running || owner && owner.sessionID !== context.sessionID && ownedPending?.flowId === owner.flowId && !ownedPending.paused) throw new Error('setup_owner_conflict');
|
|
128
|
+
beginning = true; beginningController = new AbortController(); beginningSessionID = context.sessionID;
|
|
129
|
+
const beginController = beginningController;
|
|
130
|
+
try { return await withNativeLock(directory, 'setup', async () => {
|
|
131
|
+
const activePending = readPending(directory), priorOwner = await savedOwner(activePending);
|
|
132
|
+
if (activePending && (!priorOwner || priorOwner.flowId !== activePending.flowId)) { last = { state: 'setup_owner_recovery_required' }; throw new Error('setup_owner_recovery_required'); }
|
|
133
|
+
if (activePending && !activePending.paused && priorOwner?.sessionID !== context.sessionID) throw new Error('setup_owner_conflict');
|
|
134
|
+
const info = await verify(context.sessionID);
|
|
135
|
+
if (!info || priorOwner?.sessionID === context.sessionID && priorOwner.binding !== binding(info)) throw new Error('session_mismatch');
|
|
136
|
+
const helper = await loadHelper(), options = { directory, brokerUrl: url.href, clientType: 'opencode' };
|
|
137
|
+
const previous = helper.readApprovalState(options), selected = readSelection(directory);
|
|
138
|
+
// An approved but uncommitted candidate is resumed, never replaced. This
|
|
139
|
+
// also reconciles a crash after private receipt write or failed attachment.
|
|
140
|
+
const resumeApproved = previous?.state === 'approved' && activePending?.brokerUrl === url.href
|
|
141
|
+
&& activePending.requestId === previous.requestId && selected?.connectionFile !== previous.connectionFile;
|
|
142
|
+
const replace = previous && previous.state !== 'pending' && !resumeApproved;
|
|
143
|
+
if (replace && (context.confirmedReplacement !== true || context.replacementRequestId !== previous.requestId)) return { ...publicState(previous), ...status(context.sessionID), replacement_confirmation_required: true };
|
|
144
|
+
if (beginController.signal.aborted || operationSignal.aborted) throw new Error('setup_cancelled');
|
|
145
|
+
const sameRequest = !replace && previous?.requestId && activePending?.brokerUrl === url.href && activePending.requestId === previous.requestId;
|
|
146
|
+
const pending = savePending({ directory, brokerUrl: url.href, expectedRevision: sameRequest ? activePending.expectedRevision : selected?.revision ?? null,
|
|
147
|
+
requestId: replace ? null : previous?.requestId ?? null, previousRequestId: replace ? previous.requestId : undefined, owner: { sessionID: context.sessionID, binding: binding(info) } });
|
|
148
|
+
owner = { ...pending.owner, flowId: pending.flowId }; ownerController = new AbortController();
|
|
149
|
+
const flowController = ownerController;
|
|
150
|
+
const cancelled = () => operationSignal.aborted || beginController.signal.aborted || flowController.signal.aborted
|
|
151
|
+
|| readPending(directory)?.flowId !== pending.flowId || readPending(directory)?.paused;
|
|
152
|
+
await storage.set('psst-v2-setup-owner', owner);
|
|
153
|
+
if (!await bound(context.sessionID) || cancelled()) { await pauseOwned(); throw new Error('setup_cancelled'); }
|
|
154
|
+
let result;
|
|
155
|
+
try { result = await helper.beginApproval({ ...options, ...(replace ? { replaceRequestId: previous.requestId } : {}) }); }
|
|
156
|
+
catch (error) {
|
|
157
|
+
const saved = helper.readApprovalState(options);
|
|
158
|
+
if (saved?.requestId) {
|
|
159
|
+
updatePending({ directory, flowId: pending.flowId, requestId: saved.requestId }); remember(saved);
|
|
160
|
+
if (error.retryable && await bound(context.sessionID) && !cancelled()) watch({ ...pending, requestId: saved.requestId }, context.sessionID);
|
|
161
|
+
else if (!error.retryable || !await bound(context.sessionID) || cancelled()) await pauseOwned();
|
|
162
|
+
}
|
|
163
|
+
throw error;
|
|
164
|
+
}
|
|
165
|
+
if (!replace && previous?.requestId && result.requestId !== previous.requestId) throw new Error('approval_request_mismatch');
|
|
166
|
+
updatePending({ directory, flowId: pending.flowId, requestId: result.requestId });
|
|
167
|
+
const current = { ...pending, requestId: result.requestId };
|
|
168
|
+
if (!await bound(context.sessionID) || cancelled()) { await pauseOwned(); return { state: 'paused', request_id: result.requestId }; }
|
|
169
|
+
try { await finish(result, current, context.sessionID, operationSignal, flowController); }
|
|
170
|
+
catch (error) { if (!error.retryable) block(current); throw error; }
|
|
171
|
+
if (result.state === 'pending') watch(current, context.sessionID);
|
|
172
|
+
return { ...publicState(result), ...status(context.sessionID) };
|
|
173
|
+
}); } finally { beginning = false; beginningController = undefined; beginningSessionID = undefined; }
|
|
174
|
+
}, context?.signal);
|
|
175
|
+
}
|
|
176
|
+
async function resume() {
|
|
177
|
+
if (!alive() || beginning || running) return;
|
|
178
|
+
try {
|
|
179
|
+
let pending = readPending(directory);
|
|
180
|
+
if (!pending || pending.flowId === blockedFlow) return;
|
|
181
|
+
const saved = await savedOwner(pending);
|
|
182
|
+
if (!saved || typeof saved.sessionID !== 'string' || saved.flowId !== pending.flowId) { last = { state: 'setup_owner_recovery_required' }; return; }
|
|
183
|
+
if (owner?.flowId !== saved.flowId) ownerController = new AbortController();
|
|
184
|
+
owner = saved;
|
|
185
|
+
const helper = await loadHelper();
|
|
186
|
+
const result = helper.readApprovalState({ directory, brokerUrl: pending.brokerUrl, clientType: 'opencode' });
|
|
187
|
+
if (result && pending.requestId && result.requestId !== pending.requestId) { last = { state: 'setup_owner_recovery_required' }; return; }
|
|
188
|
+
if (!pending.requestId) {
|
|
189
|
+
if (result?.requestId && result.requestId !== pending.previousRequestId) {
|
|
190
|
+
updatePending({ directory, flowId: pending.flowId, requestId: result.requestId });
|
|
191
|
+
pending = { ...pending, requestId: result.requestId };
|
|
192
|
+
} else {
|
|
193
|
+
// A crash before helper candidate persistence is not permission for a
|
|
194
|
+
// startup POST, nor to select the prior approval as the new candidate.
|
|
195
|
+
updatePending({ directory, flowId: pending.flowId, paused: true });
|
|
196
|
+
last = { ...publicState(result), state: 'paused' }; return;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
remember(result ?? { state: 'pending', requestId: pending.requestId }, pending);
|
|
200
|
+
if (!pending.paused && pending.requestId && await bound(saved.sessionID)) watch(pending, saved.sessionID);
|
|
201
|
+
} catch { last = { state: 'setup_blocked' }; throw new Error('setup_recovery_blocked'); }
|
|
202
|
+
}
|
|
203
|
+
return { invoke, resume, event(event) {
|
|
204
|
+
if (owner?.sessionID === event?.data?.sessionID && ['session.deleted', 'session.archived', 'session.moved'].includes(event.type)) {
|
|
205
|
+
ownerController.abort(); void pauseOwned().catch(() => {});
|
|
206
|
+
}
|
|
207
|
+
}, async stop() { stopped = true; lifetime.abort(); await scope.close(); await running; } };
|
|
208
|
+
}
|