@awiki/dsh-plugin 0.3.2 → 0.3.4

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 (62) hide show
  1. package/README.md +36 -22
  2. package/README.zh.md +29 -19
  3. package/cordis.patch.yml +17 -0
  4. package/lib/client.js +1273 -216
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +874 -266
  7. package/lib/provider.js +52 -16
  8. package/lib/{sdk-adapter-C0gdpg1u.mjs → sdk-adapter-BeJakxLP.mjs} +141 -3
  9. package/lib/typert.host.js +376 -44
  10. package/lib/typert.remote-client.d.ts +19 -3
  11. package/lib/typert.remote-client.d.ts.map +1 -1
  12. package/lib/typert.remote-client.js +376 -44
  13. package/lib/types/client/AwikiDevices.d.ts +28 -0
  14. package/lib/types/client/AwikiDevices.d.ts.map +1 -0
  15. package/lib/types/client/AwikiDevices.js +85 -0
  16. package/lib/types/client/AwikiDevices.js.map +1 -0
  17. package/lib/types/client/AwikiIdentityAccess.d.ts +6 -3
  18. package/lib/types/client/AwikiIdentityAccess.d.ts.map +1 -1
  19. package/lib/types/client/AwikiIdentityAccess.js +88 -27
  20. package/lib/types/client/AwikiIdentityAccess.js.map +1 -1
  21. package/lib/types/client/AwikiOverlay.d.ts.map +1 -1
  22. package/lib/types/client/AwikiOverlay.js +4 -3
  23. package/lib/types/client/AwikiOverlay.js.map +1 -1
  24. package/lib/types/client/controller.d.ts +20 -3
  25. package/lib/types/client/controller.d.ts.map +1 -1
  26. package/lib/types/client/controller.js +38 -3
  27. package/lib/types/client/controller.js.map +1 -1
  28. package/lib/types/client/index.d.ts.map +1 -1
  29. package/lib/types/client/index.js +8 -0
  30. package/lib/types/client/index.js.map +1 -1
  31. package/lib/types/client/slots.d.ts +10 -2
  32. package/lib/types/client/slots.d.ts.map +1 -1
  33. package/lib/types/index.d.ts +50 -9
  34. package/lib/types/index.d.ts.map +1 -1
  35. package/lib/types/index.js +672 -157
  36. package/lib/types/index.js.map +1 -1
  37. package/lib/types/listener-state.d.ts +5 -3
  38. package/lib/types/listener-state.d.ts.map +1 -1
  39. package/lib/types/listener-state.js +28 -13
  40. package/lib/types/listener-state.js.map +1 -1
  41. package/lib/types/listener.d.ts +9 -32
  42. package/lib/types/listener.d.ts.map +1 -1
  43. package/lib/types/listener.js +11 -127
  44. package/lib/types/listener.js.map +1 -1
  45. package/lib/types/provider-api.d.ts +96 -4
  46. package/lib/types/provider-api.d.ts.map +1 -1
  47. package/lib/types/provider.d.ts +7 -1
  48. package/lib/types/provider.d.ts.map +1 -1
  49. package/lib/types/provider.js +53 -15
  50. package/lib/types/provider.js.map +1 -1
  51. package/lib/types/realtime-supervisor.d.ts +43 -0
  52. package/lib/types/realtime-supervisor.d.ts.map +1 -0
  53. package/lib/types/realtime-supervisor.js +166 -0
  54. package/lib/types/realtime-supervisor.js.map +1 -0
  55. package/lib/types/sdk-adapter.d.ts +28 -2
  56. package/lib/types/sdk-adapter.d.ts.map +1 -1
  57. package/lib/types/sdk-adapter.js +131 -2
  58. package/lib/types/sdk-adapter.js.map +1 -1
  59. package/lib/types/types.d.ts +75 -1
  60. package/lib/types/types.d.ts.map +1 -1
  61. package/lib/types/types.js.map +1 -1
  62. package/package.json +5 -3
@@ -34,6 +34,7 @@ var __esDecorate = (this && this.__esDecorate) || function (ctor, descriptorIn,
34
34
  done = true;
35
35
  };
36
36
  import { Context } from '@deepseek-ai/cordis';
37
+ import { createHash, randomUUID, timingSafeEqual } from 'node:crypto';
37
38
  import { homedir } from 'node:os';
38
39
  import { isAbsolute, join } from 'node:path';
39
40
  import z from '@deepseek-ai/schemastery';
@@ -53,6 +54,7 @@ import { AwikiImageAttachmentCache, minimumImageAttachmentCacheMaxBytes } from "
53
54
  import { AwikiSentMailStore, isLocalSentMailId } from "./sent-mail-store.js";
54
55
  import { AwikiConversationPreferenceStore, normalizeConversationPreferenceMutation, } from "./conversation-preferences.js";
55
56
  import { AwikiAgentListener, DshAwikiListenerAgentRuntime, } from "./listener.js";
57
+ import { IdentityRealtimeSupervisor, } from "./realtime-supervisor.js";
56
58
  export { AWIKI_CLEAR_LOCAL_DATA_CONFIRMATION, AWIKI_LOGOUT_CONFIRMATION } from "./types.js";
57
59
  export { AWIKI_EXTERNAL_HTTP_MAX_BODY_BYTES, AwikiExternalHttpAuthError, } from "./external-http-auth.js";
58
60
  export { AWIKI_DOMAIN_FIELD, AWIKI_SETTINGS_NAMESPACE, AwikiSettingsSchema, DEFAULT_AWIKI_DOMAIN, normalizeAwikiDomain, validateAwikiSettings, } from "./settings.js";
@@ -85,6 +87,7 @@ export const Config = z.object({
85
87
  attachmentMaxBytes: z.number().default(DEFAULT_ATTACHMENT_MAX_BYTES),
86
88
  imageAttachmentCacheMaxBytes: z.number().default(DEFAULT_IMAGE_ATTACHMENT_CACHE_MAX_BYTES),
87
89
  pollIntervalMs: z.number().default(DEFAULT_POLL_INTERVAL_MS),
90
+ realtimeEnabled: z.boolean().default(true),
88
91
  listenerEnabled: z.boolean().default(false),
89
92
  listenerAllowedPeers: z.array(z.string()).default([]),
90
93
  listenerWorkspacePath: z.string(),
@@ -115,10 +118,17 @@ const FAILURE_CODES = new Set([
115
118
  'network',
116
119
  'remote',
117
120
  ]);
118
- const LISTENER_RESTART_BASE_DELAY_MS = 1_000;
119
- const LISTENER_RESTART_MAX_DELAY_MS = 30_000;
120
- const LISTENER_RESTART_MAX_ATTEMPT = 6;
121
- const LISTENER_STABLE_RESET_MS = 60_000;
121
+ const DEVICE_JOIN_APPROVAL_CONFIRMATION = 'APPROVE';
122
+ const DEVICE_REVOKE_CONFIRMATION = 'REVOKE';
123
+ const DEVICE_JOIN_CHALLENGE_TTL_SECONDS = 240;
124
+ const RESUMABLE_JOIN_PHASES = new Set([
125
+ 'pending',
126
+ 'challenge_prepared',
127
+ 'response_prepared',
128
+ 'response_verified',
129
+ 'approval_prepared',
130
+ 'authorized',
131
+ ]);
122
132
  const FAILURE_MESSAGES = {
123
133
  'not-registered': 'No AWiki identity is registered for this deployment.',
124
134
  'signed-out': 'This installation is signed out of AWiki.',
@@ -148,6 +158,67 @@ class ProviderUnavailableError extends Error {
148
158
  }
149
159
  class SummaryProviderUnavailableError extends Error {
150
160
  }
161
+ function candidateJoinPhase(value) {
162
+ if (value.remoteState === 'rejected')
163
+ return 'rejected';
164
+ if (value.remoteState === 'cancelled' || value.localPhase === 'cancelled')
165
+ return 'cancelled';
166
+ if (value.remoteState === 'expired' || value.localPhase === 'expired')
167
+ return 'expired';
168
+ if (value.localPhase === 'authorized'
169
+ && value.remoteState === 'consumed'
170
+ && value.completed
171
+ && value.identity !== undefined)
172
+ return 'authorized';
173
+ if (value.sas !== undefined) {
174
+ return value.remoteState === 'response_verified' && /^\d{6}$/u.test(value.sas)
175
+ ? 'sas-ready'
176
+ : undefined;
177
+ }
178
+ if (value.remoteState === 'challenge_sent'
179
+ || value.remoteState === 'response_verified'
180
+ || value.remoteState === 'consumed'
181
+ || value.localPhase !== 'pending')
182
+ return 'verifying';
183
+ return value.remoteState === 'pending' ? 'pending' : undefined;
184
+ }
185
+ function adminJoinPhase(value) {
186
+ if (value.remoteState === 'rejected')
187
+ return 'rejected';
188
+ if (value.remoteState === 'cancelled' || value.localPhase === 'cancelled')
189
+ return 'cancelled';
190
+ if (value.remoteState === 'expired' || value.localPhase === 'expired')
191
+ return 'expired';
192
+ if (value.localPhase === 'authorized' && value.remoteState === 'consumed')
193
+ return 'authorized';
194
+ if (value.sas !== undefined) {
195
+ return value.remoteState === 'response_verified' && /^\d{6}$/u.test(value.sas)
196
+ ? 'sas-ready'
197
+ : undefined;
198
+ }
199
+ if (value.remoteState === 'challenge_sent'
200
+ || value.remoteState === 'response_verified'
201
+ || value.remoteState === 'consumed'
202
+ || value.localPhase !== 'pending')
203
+ return 'verifying';
204
+ return value.remoteState === 'pending' ? 'pending' : undefined;
205
+ }
206
+ function requestJoinPhase(value) {
207
+ switch (value.state) {
208
+ case 'pending': return 'pending';
209
+ case 'challenge_sent':
210
+ case 'response_verified': return 'verifying';
211
+ case 'consumed': return 'authorized';
212
+ case 'cancelled': return 'cancelled';
213
+ case 'rejected': return 'rejected';
214
+ case 'expired': return 'expired';
215
+ }
216
+ }
217
+ function constantTimeSasMatches(expected, actual) {
218
+ if (!/^\d{6}$/u.test(expected) || !/^\d{6}$/u.test(actual))
219
+ return false;
220
+ return timingSafeEqual(Buffer.from(expected, 'ascii'), Buffer.from(actual, 'ascii'));
221
+ }
151
222
  /** Validate and preserve one SDK service URL without accepting insecure remote HTTP. */
152
223
  function serviceUrl(field, raw, allowInsecureLoopbackForTesting) {
153
224
  let url;
@@ -275,6 +346,10 @@ function resolveConfig(config) {
275
346
  throw new TypeError('awiki: pollIntervalMs must be a safe integer from 1000 through 60000');
276
347
  }
277
348
  const listenerEnabled = config.listenerEnabled ?? false;
349
+ const realtimeEnabled = config.realtimeEnabled ?? true;
350
+ if (listenerEnabled && !realtimeEnabled) {
351
+ throw new TypeError('awiki: listenerEnabled requires realtimeEnabled');
352
+ }
278
353
  const listenerWorkspacePath = config.listenerWorkspacePath?.trim() || join(dshHome, 'workspaces', 'awiki');
279
354
  if (!isAbsolute(listenerWorkspacePath)) {
280
355
  throw new TypeError('awiki: listenerWorkspacePath must be an absolute path');
@@ -301,6 +376,7 @@ function resolveConfig(config) {
301
376
  attachmentMaxBytes,
302
377
  imageAttachmentCacheMaxBytes,
303
378
  pollIntervalMs,
379
+ realtimeEnabled,
304
380
  listenerEnabled,
305
381
  listener: {
306
382
  allowedPeers,
@@ -461,6 +537,7 @@ function normalizeRecoveryPrepareRequest(request) {
461
537
  return { ...operation, phone, otp };
462
538
  }
463
539
  const IDENTITY_ACCESS_RESPONSE_MAX_BYTES = 64 * 1024;
540
+ const SERVER_INFO_RESPONSE_MAX_BYTES = 64 * 1024;
464
541
  const RECOVERY_RECONCILIATION_RESPONSE_MAX_BYTES = 4 * 1024;
465
542
  /** Read one untrusted discovery response without buffering beyond the fixed Host limit. */
466
543
  async function readBoundedResponseText(response, maxBytes) {
@@ -690,6 +767,14 @@ let AwikiService = (() => {
690
767
  let _inspectIdentityAccess_decorators;
691
768
  let _sendRegistrationOtp_decorators;
692
769
  let _registerIdentity_decorators;
770
+ let _beginDeviceJoin_decorators;
771
+ let _getDeviceJoinStatus_decorators;
772
+ let _cancelDeviceJoin_decorators;
773
+ let _refreshDeviceManagement_decorators;
774
+ let _startDeviceJoinVerification_decorators;
775
+ let _approveDeviceJoin_decorators;
776
+ let _rejectDeviceJoin_decorators;
777
+ let _revokeDevice_decorators;
693
778
  let _updateDisplayName_decorators;
694
779
  let _getProfile_decorators;
695
780
  let _updateProfile_decorators;
@@ -735,6 +820,14 @@ let AwikiService = (() => {
735
820
  _inspectIdentityAccess_decorators = [Remote];
736
821
  _sendRegistrationOtp_decorators = [Remote];
737
822
  _registerIdentity_decorators = [Remote];
823
+ _beginDeviceJoin_decorators = [Remote];
824
+ _getDeviceJoinStatus_decorators = [Remote];
825
+ _cancelDeviceJoin_decorators = [Remote];
826
+ _refreshDeviceManagement_decorators = [Remote];
827
+ _startDeviceJoinVerification_decorators = [Remote];
828
+ _approveDeviceJoin_decorators = [Remote];
829
+ _rejectDeviceJoin_decorators = [Remote];
830
+ _revokeDevice_decorators = [Remote];
738
831
  _updateDisplayName_decorators = [Remote];
739
832
  _getProfile_decorators = [Remote];
740
833
  _updateProfile_decorators = [Remote];
@@ -777,6 +870,14 @@ let AwikiService = (() => {
777
870
  __esDecorate(this, null, _inspectIdentityAccess_decorators, { kind: "method", name: "inspectIdentityAccess", static: false, private: false, access: { has: obj => "inspectIdentityAccess" in obj, get: obj => obj.inspectIdentityAccess }, metadata: _metadata }, null, _instanceExtraInitializers);
778
871
  __esDecorate(this, null, _sendRegistrationOtp_decorators, { kind: "method", name: "sendRegistrationOtp", static: false, private: false, access: { has: obj => "sendRegistrationOtp" in obj, get: obj => obj.sendRegistrationOtp }, metadata: _metadata }, null, _instanceExtraInitializers);
779
872
  __esDecorate(this, null, _registerIdentity_decorators, { kind: "method", name: "registerIdentity", static: false, private: false, access: { has: obj => "registerIdentity" in obj, get: obj => obj.registerIdentity }, metadata: _metadata }, null, _instanceExtraInitializers);
873
+ __esDecorate(this, null, _beginDeviceJoin_decorators, { kind: "method", name: "beginDeviceJoin", static: false, private: false, access: { has: obj => "beginDeviceJoin" in obj, get: obj => obj.beginDeviceJoin }, metadata: _metadata }, null, _instanceExtraInitializers);
874
+ __esDecorate(this, null, _getDeviceJoinStatus_decorators, { kind: "method", name: "getDeviceJoinStatus", static: false, private: false, access: { has: obj => "getDeviceJoinStatus" in obj, get: obj => obj.getDeviceJoinStatus }, metadata: _metadata }, null, _instanceExtraInitializers);
875
+ __esDecorate(this, null, _cancelDeviceJoin_decorators, { kind: "method", name: "cancelDeviceJoin", static: false, private: false, access: { has: obj => "cancelDeviceJoin" in obj, get: obj => obj.cancelDeviceJoin }, metadata: _metadata }, null, _instanceExtraInitializers);
876
+ __esDecorate(this, null, _refreshDeviceManagement_decorators, { kind: "method", name: "refreshDeviceManagement", static: false, private: false, access: { has: obj => "refreshDeviceManagement" in obj, get: obj => obj.refreshDeviceManagement }, metadata: _metadata }, null, _instanceExtraInitializers);
877
+ __esDecorate(this, null, _startDeviceJoinVerification_decorators, { kind: "method", name: "startDeviceJoinVerification", static: false, private: false, access: { has: obj => "startDeviceJoinVerification" in obj, get: obj => obj.startDeviceJoinVerification }, metadata: _metadata }, null, _instanceExtraInitializers);
878
+ __esDecorate(this, null, _approveDeviceJoin_decorators, { kind: "method", name: "approveDeviceJoin", static: false, private: false, access: { has: obj => "approveDeviceJoin" in obj, get: obj => obj.approveDeviceJoin }, metadata: _metadata }, null, _instanceExtraInitializers);
879
+ __esDecorate(this, null, _rejectDeviceJoin_decorators, { kind: "method", name: "rejectDeviceJoin", static: false, private: false, access: { has: obj => "rejectDeviceJoin" in obj, get: obj => obj.rejectDeviceJoin }, metadata: _metadata }, null, _instanceExtraInitializers);
880
+ __esDecorate(this, null, _revokeDevice_decorators, { kind: "method", name: "revokeDevice", static: false, private: false, access: { has: obj => "revokeDevice" in obj, get: obj => obj.revokeDevice }, metadata: _metadata }, null, _instanceExtraInitializers);
780
881
  __esDecorate(this, null, _updateDisplayName_decorators, { kind: "method", name: "updateDisplayName", static: false, private: false, access: { has: obj => "updateDisplayName" in obj, get: obj => obj.updateDisplayName }, metadata: _metadata }, null, _instanceExtraInitializers);
781
882
  __esDecorate(this, null, _getProfile_decorators, { kind: "method", name: "getProfile", static: false, private: false, access: { has: obj => "getProfile" in obj, get: obj => obj.getProfile }, metadata: _metadata }, null, _instanceExtraInitializers);
782
883
  __esDecorate(this, null, _updateProfile_decorators, { kind: "method", name: "updateProfile", static: false, private: false, access: { has: obj => "updateProfile" in obj, get: obj => obj.updateProfile }, metadata: _metadata }, null, _instanceExtraInitializers);
@@ -827,6 +928,12 @@ let AwikiService = (() => {
827
928
  sessionMutation = Promise.resolve();
828
929
  sessionRevision = 0;
829
930
  activeIdentityDid;
931
+ pendingDeviceJoin;
932
+ activeDeviceJoinSessionId;
933
+ requestRefs = new Map();
934
+ requestSessions = new Map();
935
+ deviceRefs = new Map();
936
+ deviceIds = new Map();
830
937
  activeSummaryRequests = new Set();
831
938
  summaryProvider;
832
939
  recoveryReconciliationTarget;
@@ -871,14 +978,14 @@ let AwikiService = (() => {
871
978
  this.workspaceContext = workspaceCtx;
872
979
  const provider = this.provider;
873
980
  if (provider !== undefined)
874
- void this.startListener(provider);
981
+ this.ensureAgentConsumer(provider);
875
982
  workspaceCtx.effect(() => async () => {
876
983
  if (this.workspaceContext !== workspaceCtx)
877
984
  return;
878
985
  this.workspaceContext = undefined;
879
986
  const current = this.provider;
880
987
  if (current !== undefined)
881
- await this.stopListener(current);
988
+ await this.stopAgentConsumer(current);
882
989
  }, 'awiki: release Workspace listener composition');
883
990
  });
884
991
  registerAwikiTools(ctx, this);
@@ -901,6 +1008,8 @@ let AwikiService = (() => {
901
1008
  registerClientFactory(factory) {
902
1009
  if (this.provider !== undefined)
903
1010
  throw new Error('awiki: a client provider is already registered');
1011
+ this.pendingDeviceJoin = undefined;
1012
+ this.activeDeviceJoinSessionId = undefined;
904
1013
  const client = factory({
905
1014
  userServiceUrl: this.resolved.userServiceUrl,
906
1015
  userServiceDomain: this.startupUserServiceDomain,
@@ -915,14 +1024,29 @@ let AwikiService = (() => {
915
1024
  });
916
1025
  const provider = {
917
1026
  client,
918
- listenerRestartAttempt: 0,
919
- listenerGeneration: 0,
920
- listenerRecoveryBlocked: false,
1027
+ realtimeGeneration: 0,
1028
+ agentConsumerGeneration: 0,
1029
+ localDeviceJoinRequestCountAfterSync: 0,
921
1030
  };
922
1031
  this.provider = provider;
923
- void this.startListener(provider);
1032
+ this.ensureRealtimeSupervisor(provider);
1033
+ this.ensureAgentConsumer(provider);
924
1034
  return () => this.disposeProvider(provider);
925
1035
  }
1036
+ /** Safe same-process diagnostics for focused E2E. Never exposed through Typert Remote. */
1037
+ getRealtimeDiagnostics() {
1038
+ const provider = this.provider;
1039
+ const realtime = provider?.realtimeSupervisor?.diagnostics() ?? {
1040
+ connected: false,
1041
+ activeSessionCount: 0,
1042
+ startCount: 0,
1043
+ stopCount: 0,
1044
+ };
1045
+ return {
1046
+ ...realtime,
1047
+ localDeviceJoinRequestCountAfterSync: provider?.localDeviceJoinRequestCountAfterSync ?? 0,
1048
+ };
1049
+ }
926
1050
  /** Register the optional Model Proxy recovery target without exposing an arbitrary callback or token. */
927
1051
  registerRecoveryReconciliationTarget(target) {
928
1052
  if (this.recoveryReconciliationTarget !== undefined) {
@@ -960,14 +1084,43 @@ let AwikiService = (() => {
960
1084
  * Read settings needed by the browser presentation.
961
1085
  * @returns Browser-safe polling configuration without SDK endpoints or state paths.
962
1086
  */
963
- getConfig() {
964
- return Promise.resolve({
1087
+ async getConfig() {
1088
+ let handleRecoveryPhoneEnabled = false;
1089
+ const abort = new AbortController();
1090
+ const timeout = setTimeout(() => { abort.abort(); }, 10_000);
1091
+ try {
1092
+ const response = await fetch(new URL('/user-service/v1/server-info', this.resolved.userServiceUrl), {
1093
+ method: 'GET',
1094
+ headers: { accept: 'application/json', 'cache-control': 'no-store' },
1095
+ cache: 'no-store',
1096
+ redirect: 'error',
1097
+ signal: abort.signal,
1098
+ });
1099
+ if (response.ok) {
1100
+ const text = await readBoundedResponseText(response, SERVER_INFO_RESPONSE_MAX_BYTES);
1101
+ const value = text === undefined ? undefined : JSON.parse(text);
1102
+ if (typeof value === 'object' && value !== null && value.schema_version === 1) {
1103
+ const methods = value.identity?.handle_recovery?.methods;
1104
+ handleRecoveryPhoneEnabled = Array.isArray(methods) && methods.some((method) => (typeof method === 'object' && method !== null
1105
+ && method.id === 'phone'
1106
+ && method.enabled === true
1107
+ && method.verification?.required === true
1108
+ && method.verification?.type === 'sms_otp'));
1109
+ }
1110
+ }
1111
+ }
1112
+ catch { }
1113
+ finally {
1114
+ clearTimeout(timeout);
1115
+ }
1116
+ return {
965
1117
  ok: true,
966
1118
  value: {
967
1119
  pollIntervalMs: this.resolved.pollIntervalMs,
968
1120
  attachmentMaxBytes: this.resolved.attachmentMaxBytes,
1121
+ handleRecoveryPhoneEnabled,
969
1122
  },
970
- });
1123
+ };
971
1124
  }
972
1125
  /**
973
1126
  * Read the deployment's identity status.
@@ -989,6 +1142,9 @@ let AwikiService = (() => {
989
1142
  if (!identity.ok)
990
1143
  return identity;
991
1144
  this.activeIdentityDid = identity.value?.did;
1145
+ const provider = this.provider;
1146
+ if (identity.value !== null && provider !== undefined)
1147
+ this.ensureProviderRuntime(provider);
992
1148
  return identity.value === null
993
1149
  ? { ok: true, value: { status: 'unregistered' } }
994
1150
  : { ok: true, value: { status: 'active', identity: identity.value } };
@@ -1015,7 +1171,7 @@ let AwikiService = (() => {
1015
1171
  this.publishSession(session);
1016
1172
  const provider = this.provider;
1017
1173
  if (provider !== undefined)
1018
- await this.stopListener(provider);
1174
+ await this.stopProviderRuntime(provider);
1019
1175
  return { ok: true, value: session };
1020
1176
  }
1021
1177
  catch {
@@ -1040,7 +1196,7 @@ let AwikiService = (() => {
1040
1196
  this.publishSession(session);
1041
1197
  const provider = this.provider;
1042
1198
  if (provider !== undefined)
1043
- void this.startListener(provider);
1199
+ this.ensureProviderRuntime(provider);
1044
1200
  return { ok: true, value: session };
1045
1201
  }
1046
1202
  catch {
@@ -1098,8 +1254,14 @@ let AwikiService = (() => {
1098
1254
  return { ok: false, error: failure('remote') };
1099
1255
  }
1100
1256
  }
1101
- sendRegistrationOtp(request) {
1102
- return this.run(client => client.sendRegistrationOtp(request));
1257
+ async sendRegistrationOtp(request) {
1258
+ this.pendingDeviceJoin = undefined;
1259
+ return this.run(async (client) => {
1260
+ if (await this.selectDeviceJoinSession(client) !== null) {
1261
+ throw Object.assign(new Error('join already exists'), { name: 'AwikiSdkError', code: 'conflict' });
1262
+ }
1263
+ return client.sendRegistrationOtp(request);
1264
+ });
1103
1265
  }
1104
1266
  /**
1105
1267
  * Register and persist the deployment's only AWiki identity.
@@ -1107,10 +1269,177 @@ let AwikiService = (() => {
1107
1269
  * @returns The new public identity or a closed failure.
1108
1270
  */
1109
1271
  async registerIdentity(request) {
1110
- const result = await this.run(client => client.registerIdentity(request));
1111
- if (result.ok)
1112
- await this.activateRegisteredIdentity(result.value);
1113
- return result;
1272
+ this.pendingDeviceJoin = undefined;
1273
+ const result = await this.run(async (client) => {
1274
+ if (await this.selectDeviceJoinSession(client) !== null) {
1275
+ throw Object.assign(new Error('join already exists'), { name: 'AwikiSdkError', code: 'conflict' });
1276
+ }
1277
+ return client.registerIdentity(request);
1278
+ });
1279
+ if (!result.ok)
1280
+ return result;
1281
+ if (result.value.status === 'registered') {
1282
+ await this.activateRegisteredIdentity(result.value.identity);
1283
+ return { ok: true, value: { status: 'registered', identity: result.value.identity } };
1284
+ }
1285
+ this.pendingDeviceJoin = {
1286
+ continuationId: result.value.continuationId,
1287
+ fullHandle: result.value.fullHandle,
1288
+ mode: result.value.mode,
1289
+ requiresUserPresence: result.value.requiresUserPresence,
1290
+ };
1291
+ return {
1292
+ ok: true,
1293
+ value: {
1294
+ status: 'join-required',
1295
+ fullHandle: result.value.fullHandle,
1296
+ mode: result.value.mode,
1297
+ requiresUserPresence: result.value.requiresUserPresence,
1298
+ },
1299
+ };
1300
+ }
1301
+ /** Consume the exact in-memory continuation; ordinary Join never claims rebind user presence. */
1302
+ async beginDeviceJoin() {
1303
+ const continuation = this.pendingDeviceJoin;
1304
+ if (continuation === undefined)
1305
+ return { ok: false, error: failure('conflict') };
1306
+ if (continuation.mode !== 'ordinary' || continuation.requiresUserPresence) {
1307
+ return { ok: false, error: failure('forbidden') };
1308
+ }
1309
+ this.pendingDeviceJoin = undefined;
1310
+ const result = await this.run(client => client.beginDeviceJoin({
1311
+ continuationId: continuation.continuationId,
1312
+ operationId: `dsh-device-join-${randomUUID()}`,
1313
+ userPresenceConfirmed: false,
1314
+ }));
1315
+ if (!result.ok)
1316
+ return result;
1317
+ this.activeDeviceJoinSessionId = result.value.joinSessionId;
1318
+ return this.applyCandidateJoinProgress(result.value);
1319
+ }
1320
+ /** Restore from Core local_sessions and advance only the exact resumable Join. */
1321
+ async getDeviceJoinStatus() {
1322
+ const result = await this.run(async (client) => {
1323
+ const joinSessionId = await this.selectDeviceJoinSession(client);
1324
+ return joinSessionId === null ? null : client.getDeviceJoinStatus(joinSessionId);
1325
+ });
1326
+ if (!result.ok)
1327
+ return result;
1328
+ if (result.value === null)
1329
+ return { ok: true, value: null };
1330
+ this.activeDeviceJoinSessionId = result.value.joinSessionId;
1331
+ return this.applyCandidateJoinProgress(result.value);
1332
+ }
1333
+ async cancelDeviceJoin() {
1334
+ const result = await this.run(async (client) => {
1335
+ const joinSessionId = await this.selectDeviceJoinSession(client);
1336
+ if (joinSessionId === null)
1337
+ return null;
1338
+ return client.cancelDeviceJoin(joinSessionId);
1339
+ });
1340
+ if (!result.ok)
1341
+ return result;
1342
+ this.pendingDeviceJoin = undefined;
1343
+ this.activeDeviceJoinSessionId = undefined;
1344
+ return { ok: true, value: { completed: true } };
1345
+ }
1346
+ /** Reliable-sync and project only Host-opaque device/request references. */
1347
+ refreshDeviceManagement() {
1348
+ return this.run(client => this.deviceManagementSnapshot(client));
1349
+ }
1350
+ async startDeviceJoinVerification(request) {
1351
+ const joinSessionId = this.requestSessions.get(request?.requestRef);
1352
+ if (joinSessionId === undefined)
1353
+ return { ok: false, error: failure('invalid-request') };
1354
+ const result = await this.run(async (client) => {
1355
+ await this.requireDeviceManager(client);
1356
+ await client.syncDeviceManagement();
1357
+ let notice = (await client.listLocalDeviceJoinRequests())
1358
+ .find(value => value.joinSessionId === joinSessionId);
1359
+ if (notice === undefined) {
1360
+ throw Object.assign(new Error('request not found'), { name: 'AwikiSdkError', code: 'not-found' });
1361
+ }
1362
+ const localProgress = await this.localAdminJoinProgress(client, notice);
1363
+ if (localProgress !== undefined)
1364
+ return localProgress;
1365
+ if (!notice.canStartVerification) {
1366
+ throw Object.assign(new Error('request unavailable'), { name: 'AwikiSdkError', code: 'forbidden' });
1367
+ }
1368
+ const operationId = `dsh-device-verify-${createHash('sha256').update(joinSessionId).digest('hex').slice(0, 32)}`;
1369
+ let started;
1370
+ try {
1371
+ started = await client.startDeviceJoinVerification({
1372
+ joinSessionId,
1373
+ operationId,
1374
+ challengeTtlSeconds: DEVICE_JOIN_CHALLENGE_TTL_SECONDS,
1375
+ });
1376
+ }
1377
+ catch (error) {
1378
+ await client.syncDeviceManagement();
1379
+ notice = (await client.listLocalDeviceJoinRequests())
1380
+ .find(value => value.joinSessionId === joinSessionId);
1381
+ const recovered = notice === undefined ? undefined : await this.localAdminJoinProgress(client, notice);
1382
+ if (recovered !== undefined)
1383
+ return recovered;
1384
+ throw error;
1385
+ }
1386
+ await client.syncDeviceManagement();
1387
+ notice = (await client.listLocalDeviceJoinRequests())
1388
+ .find(value => value.joinSessionId === joinSessionId);
1389
+ if (notice === undefined)
1390
+ return started;
1391
+ return (await this.localAdminJoinProgress(client, notice)) ?? started;
1392
+ });
1393
+ return result.ok ? this.publicAdminJoinProgress(request.requestRef, result.value) : result;
1394
+ }
1395
+ async approveDeviceJoin(request) {
1396
+ if (request?.confirmation !== DEVICE_JOIN_APPROVAL_CONFIRMATION) {
1397
+ return { ok: false, error: failure('invalid-request') };
1398
+ }
1399
+ const joinSessionId = this.requestSessions.get(request.requestRef);
1400
+ if (joinSessionId === undefined || !/^\d{6}$/u.test(request.enteredSas)) {
1401
+ return { ok: false, error: failure('invalid-request') };
1402
+ }
1403
+ const result = await this.run(async (client) => {
1404
+ await this.requireDeviceManager(client);
1405
+ const progress = await client.getLocalDeviceJoinVerificationProgress(joinSessionId);
1406
+ if (progress.sas === undefined || !constantTimeSasMatches(progress.sas, request.enteredSas)) {
1407
+ throw Object.assign(new Error('sas mismatch'), { name: 'AwikiSdkError', code: 'forbidden' });
1408
+ }
1409
+ const prompt = await client.prepareDeviceJoinApproval(joinSessionId);
1410
+ return client.confirmDeviceJoinApproval(prompt.approvalHandle);
1411
+ });
1412
+ return result.ok ? this.publicAdminJoinProgress(request.requestRef, result.value) : result;
1413
+ }
1414
+ async rejectDeviceJoin(request) {
1415
+ const joinSessionId = this.requestSessions.get(request?.requestRef);
1416
+ if (joinSessionId === undefined
1417
+ || (request.reason !== 'user_rejected' && request.reason !== 'sas_mismatch')) {
1418
+ return { ok: false, error: failure('invalid-request') };
1419
+ }
1420
+ const result = await this.run(async (client) => {
1421
+ await this.requireDeviceManager(client);
1422
+ return client.rejectDeviceJoin(joinSessionId, request.reason);
1423
+ });
1424
+ return result.ok ? this.publicAdminJoinProgress(request.requestRef, result.value) : result;
1425
+ }
1426
+ async revokeDevice(request) {
1427
+ if (request?.confirmation !== DEVICE_REVOKE_CONFIRMATION) {
1428
+ return { ok: false, error: failure('invalid-request') };
1429
+ }
1430
+ const deviceId = this.deviceIds.get(request.deviceRef);
1431
+ if (deviceId === undefined)
1432
+ return { ok: false, error: failure('invalid-request') };
1433
+ return this.run(async (client) => {
1434
+ await this.requireDeviceManager(client);
1435
+ const devices = await client.getDeviceRegistry();
1436
+ const target = devices.find(device => device.deviceId === deviceId);
1437
+ if (target === undefined || target.isCurrent) {
1438
+ throw Object.assign(new Error('device unavailable'), { name: 'AwikiSdkError', code: 'forbidden' });
1439
+ }
1440
+ await client.revokeDevice(deviceId);
1441
+ return this.deviceManagementSnapshot(client);
1442
+ });
1114
1443
  }
1115
1444
  /**
1116
1445
  * Update the deployment identity's public WNS display name.
@@ -1136,10 +1465,16 @@ let AwikiService = (() => {
1136
1465
  }
1137
1466
  /** Start durable phone recovery for one existing full Handle. */
1138
1467
  sendRecoveryOtp(request) {
1468
+ this.pendingDeviceJoin = undefined;
1139
1469
  const normalized = normalizeRecoveryOtpRequest(request);
1140
1470
  if (normalized === undefined)
1141
1471
  return Promise.resolve({ ok: false, error: failure('invalid-request') });
1142
- return this.run(client => client.sendRecoveryOtp(normalized), { allowSignedOut: true });
1472
+ return this.run(async (client) => {
1473
+ if (await this.selectDeviceJoinSession(client) !== null) {
1474
+ throw Object.assign(new Error('join already exists'), { name: 'AwikiSdkError', code: 'conflict' });
1475
+ }
1476
+ return client.sendRecoveryOtp(normalized);
1477
+ }, { allowSignedOut: true });
1143
1478
  }
1144
1479
  /** Verify a recovery OTP and freeze its exact intent before the remote commit. */
1145
1480
  prepareRecovery(request) {
@@ -1586,11 +1921,11 @@ let AwikiService = (() => {
1586
1921
  return this.mutateSession(async () => {
1587
1922
  const provider = this.provider;
1588
1923
  if (provider !== undefined)
1589
- await this.stopListener(provider);
1924
+ await this.stopProviderRuntime(provider);
1590
1925
  const result = await this.run(client => client.clearLocalData(), { allowSignedOut: true });
1591
1926
  if (!result.ok) {
1592
1927
  if (provider !== undefined && this.provider === provider)
1593
- void this.startListener(provider);
1928
+ this.ensureProviderRuntime(provider);
1594
1929
  return result;
1595
1930
  }
1596
1931
  try {
@@ -1600,10 +1935,10 @@ let AwikiService = (() => {
1600
1935
  await this.sessionStore.signIn();
1601
1936
  this.signedOut = false;
1602
1937
  this.activeIdentityDid = undefined;
1938
+ this.pendingDeviceJoin = undefined;
1939
+ this.activeDeviceJoinSessionId = undefined;
1603
1940
  this.invalidateSummaries();
1604
1941
  this.publishSession({ status: 'unregistered' });
1605
- if (provider !== undefined && this.provider === provider)
1606
- void this.startListener(provider);
1607
1942
  return result;
1608
1943
  }
1609
1944
  catch {
@@ -1636,8 +1971,7 @@ let AwikiService = (() => {
1636
1971
  if (!alreadyActive) {
1637
1972
  const session = { status: 'active', identity };
1638
1973
  this.publishSession(session);
1639
- await provider.listenerStartup;
1640
- await this.startListener(provider);
1974
+ this.ensureProviderRuntime(provider);
1641
1975
  }
1642
1976
  return reconciled;
1643
1977
  }
@@ -1678,19 +2012,164 @@ let AwikiService = (() => {
1678
2012
  return false;
1679
2013
  }
1680
2014
  }
1681
- /** Publish one newly registered identity and start its listener through the existing session path. */
2015
+ /** Select the only resumable new-device session; Core local_sessions is the sole restart SoT. */
2016
+ async selectDeviceJoinSession(client) {
2017
+ const sessions = await client.listLocalDeviceJoinSessions();
2018
+ if (this.activeDeviceJoinSessionId !== undefined) {
2019
+ const tracked = sessions.find(value => (value.side === 'new_device' && value.joinSessionId === this.activeDeviceJoinSessionId));
2020
+ if (tracked !== undefined)
2021
+ return tracked.joinSessionId;
2022
+ this.activeDeviceJoinSessionId = undefined;
2023
+ }
2024
+ const resumable = sessions.filter(value => (value.side === 'new_device' && RESUMABLE_JOIN_PHASES.has(value.localPhase)));
2025
+ if (resumable.length > 1) {
2026
+ throw Object.assign(new Error('multiple local joins'), { name: 'AwikiSdkError', code: 'conflict' });
2027
+ }
2028
+ const selected = resumable[0];
2029
+ this.activeDeviceJoinSessionId = selected?.joinSessionId;
2030
+ return selected?.joinSessionId ?? null;
2031
+ }
2032
+ async applyCandidateJoinProgress(value) {
2033
+ const phase = candidateJoinPhase(value);
2034
+ if (phase === undefined)
2035
+ return { ok: false, error: failure('remote') };
2036
+ if (phase === 'cancelled' || phase === 'rejected' || phase === 'expired') {
2037
+ this.activeDeviceJoinSessionId = undefined;
2038
+ }
2039
+ if (phase === 'authorized') {
2040
+ if (value.identity === undefined)
2041
+ return { ok: false, error: failure('remote') };
2042
+ await this.activateRegisteredIdentity(value.identity);
2043
+ }
2044
+ return {
2045
+ ok: true,
2046
+ value: {
2047
+ phase,
2048
+ expiresAt: value.expiresAt,
2049
+ ...(phase === 'sas-ready' && value.sas !== undefined ? { sas: value.sas } : {}),
2050
+ completed: phase === 'authorized',
2051
+ },
2052
+ };
2053
+ }
2054
+ publicAdminJoinProgress(requestRef, value) {
2055
+ const phase = adminJoinPhase(value);
2056
+ if (phase === undefined)
2057
+ return { ok: false, error: failure('remote') };
2058
+ return {
2059
+ ok: true,
2060
+ value: {
2061
+ requestRef,
2062
+ phase,
2063
+ expiresAt: value.expiresAt,
2064
+ ...(phase === 'sas-ready' && value.sas !== undefined ? { sas: value.sas } : {}),
2065
+ },
2066
+ };
2067
+ }
2068
+ async localAdminJoinProgress(client, notice) {
2069
+ const local = (await client.listLocalDeviceJoinSessions())
2070
+ .find(session => session.side === 'admin' && session.joinSessionId === notice.joinSessionId);
2071
+ if (local === undefined) {
2072
+ return notice.claimedByCurrentDevice
2073
+ ? client.getLocalDeviceJoinVerificationProgress(notice.joinSessionId)
2074
+ : undefined;
2075
+ }
2076
+ if (local.localPhase === 'challenge_prepared') {
2077
+ return {
2078
+ joinSessionId: notice.joinSessionId,
2079
+ localPhase: local.localPhase,
2080
+ remoteState: notice.state,
2081
+ expiresAt: local.expiresAt,
2082
+ };
2083
+ }
2084
+ return client.getLocalDeviceJoinVerificationProgress(notice.joinSessionId);
2085
+ }
2086
+ async requireDeviceManager(client) {
2087
+ const current = await client.getCurrentDeviceSummary();
2088
+ if (!current.canManage || current.role !== 'admin' || current.readiness !== 'admin_ready') {
2089
+ throw Object.assign(new Error('device manager required'), { name: 'AwikiSdkError', code: 'forbidden' });
2090
+ }
2091
+ }
2092
+ requestRef(joinSessionId) {
2093
+ const existing = this.requestRefs.get(joinSessionId);
2094
+ if (existing !== undefined)
2095
+ return existing;
2096
+ const reference = `join-${randomUUID()}`;
2097
+ this.requestRefs.set(joinSessionId, reference);
2098
+ this.requestSessions.set(reference, joinSessionId);
2099
+ return reference;
2100
+ }
2101
+ deviceRef(deviceId) {
2102
+ const existing = this.deviceRefs.get(deviceId);
2103
+ if (existing !== undefined)
2104
+ return existing;
2105
+ const reference = `device-${randomUUID()}`;
2106
+ this.deviceRefs.set(deviceId, reference);
2107
+ this.deviceIds.set(reference, deviceId);
2108
+ return reference;
2109
+ }
2110
+ async deviceManagementSnapshot(client) {
2111
+ await client.syncDeviceManagement();
2112
+ const current = await client.getCurrentDeviceSummary();
2113
+ if (!current.canManage || current.role !== 'admin' || current.readiness !== 'admin_ready') {
2114
+ this.requestRefs.clear();
2115
+ this.requestSessions.clear();
2116
+ this.deviceRefs.clear();
2117
+ this.deviceIds.clear();
2118
+ return {
2119
+ canManage: false,
2120
+ ...current.role === undefined ? {} : { role: current.role },
2121
+ readiness: current.readiness,
2122
+ devices: [],
2123
+ requests: [],
2124
+ };
2125
+ }
2126
+ const requests = await client.listLocalDeviceJoinRequests();
2127
+ await Promise.all(requests
2128
+ .filter(request => request.claimedByCurrentDevice)
2129
+ .map(request => client.getLocalDeviceJoinVerificationProgress(request.joinSessionId).catch(() => undefined)));
2130
+ const devices = await client.getDeviceRegistry();
2131
+ return {
2132
+ canManage: true,
2133
+ role: 'admin',
2134
+ readiness: 'admin_ready',
2135
+ devices: devices.map(device => this.publicDevice(device)),
2136
+ requests: requests.map(request => ({
2137
+ requestRef: this.requestRef(request.joinSessionId),
2138
+ candidateKeyFingerprint: request.candidateKeyFingerprint,
2139
+ issuedAt: request.issuedAt,
2140
+ expiresAt: request.expiresAt,
2141
+ state: requestJoinPhase(request),
2142
+ claimedByCurrentDevice: request.claimedByCurrentDevice,
2143
+ canStartVerification: request.canStartVerification,
2144
+ })),
2145
+ };
2146
+ }
2147
+ publicDevice(device) {
2148
+ return {
2149
+ deviceRef: this.deviceRef(device.deviceId),
2150
+ status: device.status,
2151
+ role: device.role,
2152
+ managementReady: device.managementReady,
2153
+ isCurrent: device.isCurrent,
2154
+ };
2155
+ }
2156
+ /** Publish one newly registered identity, then start realtime only in the background. */
1682
2157
  async activateRegisteredIdentity(identity) {
2158
+ this.pendingDeviceJoin = undefined;
2159
+ this.activeDeviceJoinSessionId = undefined;
1683
2160
  this.activeIdentityDid = identity.did;
1684
2161
  this.publishSession({ status: 'active', identity });
1685
2162
  const provider = this.provider;
1686
- if (provider !== undefined) {
1687
- await provider.listenerStartup;
1688
- await this.startListener(provider);
1689
- }
2163
+ if (provider !== undefined)
2164
+ this.ensureProviderRuntime(provider);
1690
2165
  }
1691
2166
  /** Invalidate cached session work and cancel every model request still owned by the old session. */
1692
2167
  invalidateSummaries() {
1693
2168
  this.sessionRevision += 1;
2169
+ this.requestRefs.clear();
2170
+ this.requestSessions.clear();
2171
+ this.deviceRefs.clear();
2172
+ this.deviceIds.clear();
1694
2173
  for (const controller of this.activeSummaryRequests)
1695
2174
  controller.abort();
1696
2175
  this.activeSummaryRequests.clear();
@@ -1792,158 +2271,194 @@ let AwikiService = (() => {
1792
2271
  this.sessionMutation = pending.then(() => undefined, () => undefined);
1793
2272
  return pending;
1794
2273
  }
1795
- /** Start one exact identity-bound listener, atomically releasing a failed startup. */
1796
- startListener(provider) {
1797
- if (!this.resolved.listenerEnabled
1798
- || provider.listener !== undefined
1799
- || provider.listenerRecoveryBlocked
1800
- || this.provider !== provider) {
1801
- return Promise.resolve();
1802
- }
1803
- if (provider.listenerStartup !== undefined)
1804
- return provider.listenerStartup;
1805
- if (provider.listenerCleanup !== undefined || provider.listenerRestartTimer !== undefined)
1806
- return Promise.resolve();
1807
- const workspaceContext = this.workspaceContext;
1808
- const source = provider.client.listener;
1809
- if (workspaceContext === undefined || source === undefined)
1810
- return Promise.resolve();
1811
- const generation = provider.listenerGeneration;
1812
- const logger = this.ctx.logger('awiki-listener');
2274
+ /** Start identity realtime and the optional Agent consumer without blocking identity success. */
2275
+ ensureProviderRuntime(provider) {
2276
+ const identityDid = this.activeIdentityDid;
2277
+ if (provider.runtimeReplacement !== undefined)
2278
+ return;
2279
+ if (identityDid !== undefined
2280
+ && provider.realtimeIdentityDid !== undefined
2281
+ && provider.realtimeIdentityDid !== identityDid) {
2282
+ this.replaceProviderRuntime(provider, identityDid);
2283
+ return;
2284
+ }
2285
+ this.ensureRealtimeSupervisor(provider);
2286
+ this.ensureAgentConsumer(provider);
2287
+ }
2288
+ replaceProviderRuntime(provider, identityDid) {
2289
+ if (provider.runtimeReplacement !== undefined || this.provider !== provider)
2290
+ return;
2291
+ const replacement = (async () => {
2292
+ await this.stopProviderRuntime(provider);
2293
+ if (this.provider !== provider || this.activeIdentityDid !== identityDid)
2294
+ return;
2295
+ this.ensureRealtimeSupervisor(provider);
2296
+ this.ensureAgentConsumer(provider);
2297
+ })();
2298
+ const observed = replacement
2299
+ .catch((error) => {
2300
+ this.ctx.logger('awiki-realtime').warn('AWiki identity realtime replacement failed: %s', error instanceof Error ? error.message : 'unknown failure');
2301
+ })
2302
+ .finally(() => {
2303
+ if (provider.runtimeReplacement === observed)
2304
+ delete provider.runtimeReplacement;
2305
+ if (this.provider === provider)
2306
+ this.ensureProviderRuntime(provider);
2307
+ });
2308
+ provider.runtimeReplacement = observed;
2309
+ }
2310
+ ensureRealtimeSupervisor(provider) {
2311
+ if (!this.resolved.realtimeEnabled
2312
+ || provider.realtimeSupervisor !== undefined
2313
+ || provider.realtimeStartup !== undefined
2314
+ || this.provider !== provider
2315
+ || (provider.client.realtime ?? provider.client.listener) === undefined)
2316
+ return;
2317
+ const generation = provider.realtimeGeneration;
2318
+ const source = provider.client.realtime ?? provider.client.listener;
2319
+ if (source === undefined)
2320
+ return;
2321
+ const logger = this.ctx.logger('awiki-realtime');
1813
2322
  const startup = (async () => {
1814
2323
  if (await this.isSignedOut())
1815
2324
  return;
1816
- if (await provider.client.getIdentity() === null)
2325
+ const identity = await provider.client.getIdentity();
2326
+ if (identity === null || !this.realtimeFenceMatches(provider, generation))
1817
2327
  return;
1818
- if (!this.listenerFenceMatches(provider, workspaceContext, generation))
1819
- return;
1820
- const agents = new DshAwikiListenerAgentRuntime(workspaceContext, this.resolved.listener.workspacePath);
1821
- const listener = new AwikiAgentListener(source, agents, this.resolved.listener, logger);
1822
- provider.listener = listener;
2328
+ this.activeIdentityDid = identity.did;
2329
+ const supervisor = new IdentityRealtimeSupervisor(source, {
2330
+ onSynchronized: cause => this.onRealtimeSynchronized(provider, generation, cause),
2331
+ }, logger);
2332
+ provider.realtimeSupervisor = supervisor;
2333
+ provider.realtimeIdentityDid = identity.did;
2334
+ supervisor.start();
2335
+ this.ensureAgentConsumer(provider);
2336
+ })();
2337
+ const observed = startup
2338
+ .catch((error) => {
2339
+ logger.warn('AWiki realtime startup failed: %s', error instanceof Error ? error.message : 'unknown failure');
2340
+ })
2341
+ .finally(() => {
2342
+ if (provider.realtimeStartup === observed)
2343
+ delete provider.realtimeStartup;
2344
+ });
2345
+ provider.realtimeStartup = observed;
2346
+ }
2347
+ realtimeFenceMatches(provider, generation) {
2348
+ return this.provider === provider && provider.realtimeGeneration === generation;
2349
+ }
2350
+ async onRealtimeSynchronized(provider, generation, cause) {
2351
+ if (!this.realtimeFenceMatches(provider, generation))
2352
+ return;
2353
+ if (cause === 'system_notification' || cause === 'stream_recovery') {
1823
2354
  try {
1824
- await listener.start();
2355
+ provider.localDeviceJoinRequestCountAfterSync = (await provider.client.listLocalDeviceJoinRequests()).length;
1825
2356
  }
1826
- catch (error) {
1827
- if (provider.listener === listener)
1828
- delete provider.listener;
1829
- try {
1830
- await listener.dispose();
1831
- }
1832
- catch {
1833
- provider.listenerRecoveryBlocked = true;
1834
- }
1835
- if (!provider.listenerRecoveryBlocked && provider.listenerRestartAttempt > 0) {
1836
- this.scheduleListenerRestart(provider, workspaceContext, generation);
1837
- }
1838
- throw error;
2357
+ catch {
2358
+ this.ctx.logger('awiki-realtime').debug('AWiki realtime could not observe local device Join requests');
1839
2359
  }
1840
- if (!this.listenerFenceMatches(provider, workspaceContext, generation)) {
1841
- if (provider.listener === listener)
1842
- delete provider.listener;
1843
- try {
1844
- await listener.dispose();
1845
- }
1846
- catch (error) {
1847
- provider.listenerRecoveryBlocked = true;
1848
- throw error;
1849
- }
2360
+ }
2361
+ if (!['session_start', 'connection_ready', 'reconnected', 'message', 'message_update'].includes(cause))
2362
+ return;
2363
+ this.ensureAgentConsumer(provider);
2364
+ await provider.agentConsumerStartup;
2365
+ if (!this.realtimeFenceMatches(provider, generation))
2366
+ return;
2367
+ await provider.agentConsumer?.reconcileOnce();
2368
+ }
2369
+ ensureAgentConsumer(provider) {
2370
+ const workspaceContext = this.workspaceContext;
2371
+ const source = provider.client.agentInbox ?? provider.client.listener;
2372
+ const identityDid = this.activeIdentityDid;
2373
+ if (!this.resolved.listenerEnabled
2374
+ || workspaceContext === undefined
2375
+ || source === undefined
2376
+ || identityDid === undefined
2377
+ || this.provider !== provider)
2378
+ return;
2379
+ if (provider.agentConsumer !== undefined && provider.agentConsumerIdentityDid === identityDid)
2380
+ return;
2381
+ if (provider.agentConsumerStartup !== undefined)
2382
+ return;
2383
+ const generation = ++provider.agentConsumerGeneration;
2384
+ const logger = this.ctx.logger('awiki-listener');
2385
+ const startup = (async () => {
2386
+ await this.detachAgentConsumer(provider);
2387
+ if (!this.agentConsumerFenceMatches(provider, workspaceContext, identityDid, generation))
1850
2388
  return;
1851
- }
1852
- provider.listenerStartedAt = Date.now();
1853
- void listener.whenTerminated().then((result) => {
1854
- if (result.kind === 'failed') {
1855
- this.releaseFailedListener(provider, listener, workspaceContext, generation, result.error);
1856
- }
1857
- });
2389
+ const agents = new DshAwikiListenerAgentRuntime(workspaceContext, this.resolved.listener.workspacePath);
2390
+ const consumer = new AwikiAgentListener(source, agents, {
2391
+ ...this.resolved.listener,
2392
+ identityScope: identityDid,
2393
+ }, logger);
2394
+ provider.agentConsumer = consumer;
2395
+ provider.agentConsumerIdentityDid = identityDid;
2396
+ await consumer.reconcileOnce();
1858
2397
  })();
1859
2398
  const observed = startup
1860
2399
  .catch((error) => {
1861
- logger.warn('AWiki listener startup failed: %s', error instanceof Error ? error.message : 'unknown failure');
2400
+ logger.warn('AWiki Agent consumer startup failed: %s', error instanceof Error ? error.message : 'unknown failure');
1862
2401
  })
1863
2402
  .finally(() => {
1864
- if (provider.listenerStartup === observed)
1865
- delete provider.listenerStartup;
2403
+ if (provider.agentConsumerStartup === observed)
2404
+ delete provider.agentConsumerStartup;
1866
2405
  });
1867
- provider.listenerStartup = observed;
1868
- return observed;
1869
- }
1870
- async stopListener(provider) {
1871
- provider.listenerGeneration += 1;
1872
- provider.listenerRestartAttempt = 0;
1873
- delete provider.listenerStartedAt;
1874
- const timer = provider.listenerRestartTimer;
1875
- if (timer !== undefined) {
1876
- delete provider.listenerRestartTimer;
1877
- clearTimeout(timer);
1878
- }
1879
- await provider.listenerStartup;
1880
- await provider.listenerCleanup;
1881
- const listener = provider.listener;
1882
- if (listener === undefined)
1883
- return;
1884
- delete provider.listener;
1885
- try {
1886
- await listener.dispose();
1887
- }
1888
- catch (error) {
1889
- provider.listenerRecoveryBlocked = true;
1890
- throw error;
1891
- }
2406
+ provider.agentConsumerStartup = observed;
1892
2407
  }
1893
- listenerFenceMatches(provider, workspaceContext, generation) {
2408
+ agentConsumerFenceMatches(provider, workspaceContext, identityDid, generation) {
1894
2409
  return this.provider === provider
1895
2410
  && this.workspaceContext === workspaceContext
1896
- && provider.listenerGeneration === generation;
2411
+ && this.activeIdentityDid === identityDid
2412
+ && provider.agentConsumerGeneration === generation;
1897
2413
  }
1898
- releaseFailedListener(provider, listener, workspaceContext, generation, error) {
1899
- if (provider.listener !== listener)
2414
+ async detachAgentConsumer(provider) {
2415
+ const consumer = provider.agentConsumer;
2416
+ if (consumer === undefined)
1900
2417
  return;
1901
- delete provider.listener;
1902
- const startedAt = provider.listenerStartedAt;
1903
- delete provider.listenerStartedAt;
1904
- if (startedAt !== undefined && Date.now() - startedAt >= LISTENER_STABLE_RESET_MS) {
1905
- provider.listenerRestartAttempt = 0;
1906
- }
1907
- const logger = this.ctx.logger('awiki-listener');
1908
- logger.warn('AWiki listener lifecycle failed: %s', error instanceof Error ? error.message : 'unknown failure');
1909
- let disposed = false;
1910
- const cleanup = listener.dispose()
1911
- .then(() => { disposed = true; }, (cleanupError) => {
1912
- provider.listenerRecoveryBlocked = true;
1913
- logger.warn('AWiki listener cleanup failed: %s', cleanupError instanceof Error ? cleanupError.message : 'unknown failure');
1914
- });
2418
+ delete provider.agentConsumer;
2419
+ delete provider.agentConsumerIdentityDid;
2420
+ const cleanup = consumer.dispose();
1915
2421
  const observed = cleanup.finally(() => {
1916
- if (provider.listenerCleanup === observed)
1917
- delete provider.listenerCleanup;
1918
- if (disposed)
1919
- this.scheduleListenerRestart(provider, workspaceContext, generation);
2422
+ if (provider.agentConsumerCleanup === observed)
2423
+ delete provider.agentConsumerCleanup;
1920
2424
  });
1921
- provider.listenerCleanup = observed;
1922
- }
1923
- scheduleListenerRestart(provider, workspaceContext, generation) {
1924
- if (provider.listenerRestartTimer !== undefined
1925
- || !this.listenerFenceMatches(provider, workspaceContext, generation))
2425
+ provider.agentConsumerCleanup = observed;
2426
+ await observed;
2427
+ }
2428
+ async stopAgentConsumer(provider) {
2429
+ provider.agentConsumerGeneration += 1;
2430
+ await provider.agentConsumerStartup;
2431
+ await provider.agentConsumerCleanup;
2432
+ await this.detachAgentConsumer(provider);
2433
+ }
2434
+ async stopRealtimeSupervisor(provider) {
2435
+ provider.realtimeGeneration += 1;
2436
+ await provider.realtimeStartup;
2437
+ const supervisor = provider.realtimeSupervisor;
2438
+ if (supervisor === undefined)
1926
2439
  return;
1927
- provider.listenerRestartAttempt = Math.min(provider.listenerRestartAttempt + 1, LISTENER_RESTART_MAX_ATTEMPT);
1928
- const exponent = provider.listenerRestartAttempt - 1;
1929
- const delay = Math.min(LISTENER_RESTART_BASE_DELAY_MS * 2 ** exponent, LISTENER_RESTART_MAX_DELAY_MS);
1930
- const timer = setTimeout(() => {
1931
- if (provider.listenerRestartTimer !== timer)
1932
- return;
1933
- delete provider.listenerRestartTimer;
1934
- if (!this.listenerFenceMatches(provider, workspaceContext, generation))
1935
- return;
1936
- void this.startListener(provider);
1937
- }, delay);
1938
- provider.listenerRestartTimer = timer;
2440
+ delete provider.realtimeSupervisor;
2441
+ delete provider.realtimeIdentityDid;
2442
+ await supervisor.dispose();
2443
+ }
2444
+ async stopProviderRuntime(provider) {
2445
+ await Promise.all([
2446
+ this.stopRealtimeSupervisor(provider),
2447
+ this.stopAgentConsumer(provider),
2448
+ ]);
1939
2449
  }
1940
2450
  /** Clear one exact provider slot before joining its one shared disposal. */
1941
2451
  disposeProvider(provider) {
1942
- if (this.provider === provider)
2452
+ if (this.provider === provider) {
1943
2453
  this.provider = undefined;
2454
+ this.pendingDeviceJoin = undefined;
2455
+ this.activeDeviceJoinSessionId = undefined;
2456
+ this.invalidateSummaries();
2457
+ }
1944
2458
  provider.disposal ??= (async () => {
1945
2459
  try {
1946
- await this.stopListener(provider);
2460
+ await provider.runtimeReplacement;
2461
+ await this.stopProviderRuntime(provider);
1947
2462
  }
1948
2463
  finally {
1949
2464
  await provider.client.dispose();