@awiki/dsh-plugin 0.3.3 → 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 (66) hide show
  1. package/README.md +37 -31
  2. package/README.zh.md +30 -27
  3. package/cordis.patch.yml +17 -0
  4. package/lib/client.js +1269 -272
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +874 -314
  7. package/lib/provider.js +52 -16
  8. package/lib/{sdk-adapter-CDLTgHJA.mjs → sdk-adapter-BeJakxLP.mjs} +142 -4
  9. package/lib/typert.host.js +419 -89
  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 +419 -89
  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 -5
  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 -5
  25. package/lib/types/client/controller.d.ts.map +1 -1
  26. package/lib/types/client/controller.js +38 -7
  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 +51 -10
  34. package/lib/types/index.d.ts.map +1 -1
  35. package/lib/types/index.js +675 -174
  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 +132 -3
  58. package/lib/types/sdk-adapter.js.map +1 -1
  59. package/lib/types/types.d.ts +76 -6
  60. package/lib/types/types.d.ts.map +1 -1
  61. package/lib/types/types.js.map +1 -1
  62. package/package.json +18 -18
  63. package/lib/types/profile-state.d.ts +0 -11
  64. package/lib/types/profile-state.d.ts.map +0 -1
  65. package/lib/types/profile-state.js +0 -48
  66. package/lib/types/profile-state.js.map +0 -1
@@ -34,7 +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 { existsSync } from 'node:fs';
37
+ import { createHash, randomUUID, timingSafeEqual } from 'node:crypto';
38
38
  import { homedir } from 'node:os';
39
39
  import { isAbsolute, join } from 'node:path';
40
40
  import z from '@deepseek-ai/schemastery';
@@ -54,7 +54,7 @@ import { AwikiImageAttachmentCache, minimumImageAttachmentCacheMaxBytes } from "
54
54
  import { AwikiSentMailStore, isLocalSentMailId } from "./sent-mail-store.js";
55
55
  import { AwikiConversationPreferenceStore, normalizeConversationPreferenceMutation, } from "./conversation-preferences.js";
56
56
  import { AwikiAgentListener, DshAwikiListenerAgentRuntime, } from "./listener.js";
57
- import { resolveAwikiProfileName, resolveAwikiStateRoot } from "./profile-state.js";
57
+ import { IdentityRealtimeSupervisor, } from "./realtime-supervisor.js";
58
58
  export { AWIKI_CLEAR_LOCAL_DATA_CONFIRMATION, AWIKI_LOGOUT_CONFIRMATION } from "./types.js";
59
59
  export { AWIKI_EXTERNAL_HTTP_MAX_BODY_BYTES, AwikiExternalHttpAuthError, } from "./external-http-auth.js";
60
60
  export { AWIKI_DOMAIN_FIELD, AWIKI_SETTINGS_NAMESPACE, AwikiSettingsSchema, DEFAULT_AWIKI_DOMAIN, normalizeAwikiDomain, validateAwikiSettings, } from "./settings.js";
@@ -87,6 +87,7 @@ export const Config = z.object({
87
87
  attachmentMaxBytes: z.number().default(DEFAULT_ATTACHMENT_MAX_BYTES),
88
88
  imageAttachmentCacheMaxBytes: z.number().default(DEFAULT_IMAGE_ATTACHMENT_CACHE_MAX_BYTES),
89
89
  pollIntervalMs: z.number().default(DEFAULT_POLL_INTERVAL_MS),
90
+ realtimeEnabled: z.boolean().default(true),
90
91
  listenerEnabled: z.boolean().default(false),
91
92
  listenerAllowedPeers: z.array(z.string()).default([]),
92
93
  listenerWorkspacePath: z.string(),
@@ -104,7 +105,6 @@ const FAILURE_CODES = new Set([
104
105
  'forbidden',
105
106
  'identity-recovery-required',
106
107
  'conflict',
107
- 'state-in-use',
108
108
  'rate-limited',
109
109
  'group-membership-required',
110
110
  'group-identity-stale',
@@ -118,10 +118,17 @@ const FAILURE_CODES = new Set([
118
118
  'network',
119
119
  'remote',
120
120
  ]);
121
- const LISTENER_RESTART_BASE_DELAY_MS = 1_000;
122
- const LISTENER_RESTART_MAX_DELAY_MS = 30_000;
123
- const LISTENER_RESTART_MAX_ATTEMPT = 6;
124
- 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
+ ]);
125
132
  const FAILURE_MESSAGES = {
126
133
  'not-registered': 'No AWiki identity is registered for this deployment.',
127
134
  'signed-out': 'This installation is signed out of AWiki.',
@@ -134,7 +141,6 @@ const FAILURE_MESSAGES = {
134
141
  'forbidden': 'The AWiki operation is not permitted.',
135
142
  'identity-recovery-required': 'The local AWiki identity must be recovered before it can be used again.',
136
143
  'conflict': 'The AWiki operation conflicts with current state.',
137
- 'state-in-use': 'AWiki data is already open in another client.',
138
144
  'rate-limited': 'The AWiki service rate-limited the request.',
139
145
  'group-membership-required': 'The active AWiki identity is not a member of this group.',
140
146
  'group-identity-stale': 'The AWiki group identity binding is still recovering.',
@@ -152,6 +158,67 @@ class ProviderUnavailableError extends Error {
152
158
  }
153
159
  class SummaryProviderUnavailableError extends Error {
154
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
+ }
155
222
  /** Validate and preserve one SDK service URL without accepting insecure remote HTTP. */
156
223
  function serviceUrl(field, raw, allowInsecureLoopbackForTesting) {
157
224
  let url;
@@ -253,21 +320,16 @@ function listenerAllowedPeers(raw, enabled) {
253
320
  return peers;
254
321
  }
255
322
  /** Resolve and validate every deployment choice before publishing the service. */
256
- function resolveConfig(ctx, config) {
323
+ function resolveConfig(config) {
257
324
  const allowInsecureLoopbackForTesting = config.allowInsecureLoopbackForTesting ?? false;
258
325
  const configuredStateRoot = config.stateRoot?.trim();
259
326
  const configuredDshHome = process.env.DSH_HOME?.trim();
260
327
  const dshHome = configuredDshHome === undefined || configuredDshHome.length === 0
261
328
  ? join(homedir(), '.dsh')
262
329
  : configuredDshHome;
263
- const profileName = configuredStateRoot === undefined || configuredStateRoot.length === 0
264
- ? resolveAwikiProfileName(ctx, dshHome)
265
- : undefined;
266
330
  const stateRoot = configuredStateRoot === undefined || configuredStateRoot.length === 0
267
- ? resolveAwikiStateRoot(ctx, dshHome)
331
+ ? join(dshHome, 'awiki', 'im-core')
268
332
  : configuredStateRoot;
269
- const legacySharedStateDetected = profileName !== undefined
270
- && existsSync(join(dshHome, 'awiki', 'im-core'));
271
333
  if (!isAbsolute(stateRoot))
272
334
  throw new TypeError('awiki: stateRoot must be an absolute path');
273
335
  const attachmentMaxBytes = config.attachmentMaxBytes ?? DEFAULT_ATTACHMENT_MAX_BYTES;
@@ -284,6 +346,10 @@ function resolveConfig(ctx, config) {
284
346
  throw new TypeError('awiki: pollIntervalMs must be a safe integer from 1000 through 60000');
285
347
  }
286
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
+ }
287
353
  const listenerWorkspacePath = config.listenerWorkspacePath?.trim() || join(dshHome, 'workspaces', 'awiki');
288
354
  if (!isAbsolute(listenerWorkspacePath)) {
289
355
  throw new TypeError('awiki: listenerWorkspacePath must be an absolute path');
@@ -310,7 +376,7 @@ function resolveConfig(ctx, config) {
310
376
  attachmentMaxBytes,
311
377
  imageAttachmentCacheMaxBytes,
312
378
  pollIntervalMs,
313
- ...profileName === undefined ? {} : { profileName, legacySharedStateDetected },
379
+ realtimeEnabled,
314
380
  listenerEnabled,
315
381
  listener: {
316
382
  allowedPeers,
@@ -471,6 +537,7 @@ function normalizeRecoveryPrepareRequest(request) {
471
537
  return { ...operation, phone, otp };
472
538
  }
473
539
  const IDENTITY_ACCESS_RESPONSE_MAX_BYTES = 64 * 1024;
540
+ const SERVER_INFO_RESPONSE_MAX_BYTES = 64 * 1024;
474
541
  const RECOVERY_RECONCILIATION_RESPONSE_MAX_BYTES = 4 * 1024;
475
542
  /** Read one untrusted discovery response without buffering beyond the fixed Host limit. */
476
543
  async function readBoundedResponseText(response, maxBytes) {
@@ -700,6 +767,14 @@ let AwikiService = (() => {
700
767
  let _inspectIdentityAccess_decorators;
701
768
  let _sendRegistrationOtp_decorators;
702
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;
703
778
  let _updateDisplayName_decorators;
704
779
  let _getProfile_decorators;
705
780
  let _updateProfile_decorators;
@@ -745,6 +820,14 @@ let AwikiService = (() => {
745
820
  _inspectIdentityAccess_decorators = [Remote];
746
821
  _sendRegistrationOtp_decorators = [Remote];
747
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];
748
831
  _updateDisplayName_decorators = [Remote];
749
832
  _getProfile_decorators = [Remote];
750
833
  _updateProfile_decorators = [Remote];
@@ -787,6 +870,14 @@ let AwikiService = (() => {
787
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);
788
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);
789
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);
790
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);
791
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);
792
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);
@@ -837,6 +928,12 @@ let AwikiService = (() => {
837
928
  sessionMutation = Promise.resolve();
838
929
  sessionRevision = 0;
839
930
  activeIdentityDid;
931
+ pendingDeviceJoin;
932
+ activeDeviceJoinSessionId;
933
+ requestRefs = new Map();
934
+ requestSessions = new Map();
935
+ deviceRefs = new Map();
936
+ deviceIds = new Map();
840
937
  activeSummaryRequests = new Set();
841
938
  summaryProvider;
842
939
  recoveryReconciliationTarget;
@@ -851,7 +948,7 @@ let AwikiService = (() => {
851
948
  constructor(ctx, config) {
852
949
  super(ctx, 'awiki');
853
950
  this.hostContext = ctx;
854
- this.resolved = resolveConfig(ctx, config);
951
+ this.resolved = resolveConfig(config);
855
952
  this.externalHttpAuth = createAwikiExternalHttpAuth(() => this.acquireExternalHttpAuthSession());
856
953
  this.sessionStore = new AwikiSessionStore(this.resolved.stateRoot);
857
954
  this.imageAttachmentCache = new AwikiImageAttachmentCache(this.resolved.stateRoot, this.resolved.attachmentMaxBytes, this.resolved.imageAttachmentCacheMaxBytes);
@@ -881,14 +978,14 @@ let AwikiService = (() => {
881
978
  this.workspaceContext = workspaceCtx;
882
979
  const provider = this.provider;
883
980
  if (provider !== undefined)
884
- void this.startListener(provider);
981
+ this.ensureAgentConsumer(provider);
885
982
  workspaceCtx.effect(() => async () => {
886
983
  if (this.workspaceContext !== workspaceCtx)
887
984
  return;
888
985
  this.workspaceContext = undefined;
889
986
  const current = this.provider;
890
987
  if (current !== undefined)
891
- await this.stopListener(current);
988
+ await this.stopAgentConsumer(current);
892
989
  }, 'awiki: release Workspace listener composition');
893
990
  });
894
991
  registerAwikiTools(ctx, this);
@@ -911,6 +1008,8 @@ let AwikiService = (() => {
911
1008
  registerClientFactory(factory) {
912
1009
  if (this.provider !== undefined)
913
1010
  throw new Error('awiki: a client provider is already registered');
1011
+ this.pendingDeviceJoin = undefined;
1012
+ this.activeDeviceJoinSessionId = undefined;
914
1013
  const client = factory({
915
1014
  userServiceUrl: this.resolved.userServiceUrl,
916
1015
  userServiceDomain: this.startupUserServiceDomain,
@@ -925,14 +1024,29 @@ let AwikiService = (() => {
925
1024
  });
926
1025
  const provider = {
927
1026
  client,
928
- listenerRestartAttempt: 0,
929
- listenerGeneration: 0,
930
- listenerRecoveryBlocked: false,
1027
+ realtimeGeneration: 0,
1028
+ agentConsumerGeneration: 0,
1029
+ localDeviceJoinRequestCountAfterSync: 0,
931
1030
  };
932
1031
  this.provider = provider;
933
- void this.startListener(provider);
1032
+ this.ensureRealtimeSupervisor(provider);
1033
+ this.ensureAgentConsumer(provider);
934
1034
  return () => this.disposeProvider(provider);
935
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
+ }
936
1050
  /** Register the optional Model Proxy recovery target without exposing an arbitrary callback or token. */
937
1051
  registerRecoveryReconciliationTarget(target) {
938
1052
  if (this.recoveryReconciliationTarget !== undefined) {
@@ -970,18 +1084,43 @@ let AwikiService = (() => {
970
1084
  * Read settings needed by the browser presentation.
971
1085
  * @returns Browser-safe polling configuration without SDK endpoints or state paths.
972
1086
  */
973
- getConfig() {
974
- 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 {
975
1117
  ok: true,
976
1118
  value: {
977
1119
  pollIntervalMs: this.resolved.pollIntervalMs,
978
1120
  attachmentMaxBytes: this.resolved.attachmentMaxBytes,
979
- ...this.resolved.profileName === undefined ? {} : {
980
- profileName: this.resolved.profileName,
981
- legacySharedStateDetected: this.resolved.legacySharedStateDetected,
982
- },
1121
+ handleRecoveryPhoneEnabled,
983
1122
  },
984
- });
1123
+ };
985
1124
  }
986
1125
  /**
987
1126
  * Read the deployment's identity status.
@@ -1003,6 +1142,9 @@ let AwikiService = (() => {
1003
1142
  if (!identity.ok)
1004
1143
  return identity;
1005
1144
  this.activeIdentityDid = identity.value?.did;
1145
+ const provider = this.provider;
1146
+ if (identity.value !== null && provider !== undefined)
1147
+ this.ensureProviderRuntime(provider);
1006
1148
  return identity.value === null
1007
1149
  ? { ok: true, value: { status: 'unregistered' } }
1008
1150
  : { ok: true, value: { status: 'active', identity: identity.value } };
@@ -1029,7 +1171,7 @@ let AwikiService = (() => {
1029
1171
  this.publishSession(session);
1030
1172
  const provider = this.provider;
1031
1173
  if (provider !== undefined)
1032
- await this.stopListener(provider);
1174
+ await this.stopProviderRuntime(provider);
1033
1175
  return { ok: true, value: session };
1034
1176
  }
1035
1177
  catch {
@@ -1054,7 +1196,7 @@ let AwikiService = (() => {
1054
1196
  this.publishSession(session);
1055
1197
  const provider = this.provider;
1056
1198
  if (provider !== undefined)
1057
- void this.startListener(provider);
1199
+ this.ensureProviderRuntime(provider);
1058
1200
  return { ok: true, value: session };
1059
1201
  }
1060
1202
  catch {
@@ -1112,8 +1254,14 @@ let AwikiService = (() => {
1112
1254
  return { ok: false, error: failure('remote') };
1113
1255
  }
1114
1256
  }
1115
- sendRegistrationOtp(request) {
1116
- 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
+ });
1117
1265
  }
1118
1266
  /**
1119
1267
  * Register and persist the deployment's only AWiki identity.
@@ -1121,10 +1269,177 @@ let AwikiService = (() => {
1121
1269
  * @returns The new public identity or a closed failure.
1122
1270
  */
1123
1271
  async registerIdentity(request) {
1124
- const result = await this.run(client => client.registerIdentity(request));
1125
- if (result.ok)
1126
- await this.activateRegisteredIdentity(result.value);
1127
- 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
+ });
1128
1443
  }
1129
1444
  /**
1130
1445
  * Update the deployment identity's public WNS display name.
@@ -1150,10 +1465,16 @@ let AwikiService = (() => {
1150
1465
  }
1151
1466
  /** Start durable phone recovery for one existing full Handle. */
1152
1467
  sendRecoveryOtp(request) {
1468
+ this.pendingDeviceJoin = undefined;
1153
1469
  const normalized = normalizeRecoveryOtpRequest(request);
1154
1470
  if (normalized === undefined)
1155
1471
  return Promise.resolve({ ok: false, error: failure('invalid-request') });
1156
- 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 });
1157
1478
  }
1158
1479
  /** Verify a recovery OTP and freeze its exact intent before the remote commit. */
1159
1480
  prepareRecovery(request) {
@@ -1600,11 +1921,11 @@ let AwikiService = (() => {
1600
1921
  return this.mutateSession(async () => {
1601
1922
  const provider = this.provider;
1602
1923
  if (provider !== undefined)
1603
- await this.stopListener(provider);
1924
+ await this.stopProviderRuntime(provider);
1604
1925
  const result = await this.run(client => client.clearLocalData(), { allowSignedOut: true });
1605
1926
  if (!result.ok) {
1606
1927
  if (provider !== undefined && this.provider === provider)
1607
- void this.startListener(provider);
1928
+ this.ensureProviderRuntime(provider);
1608
1929
  return result;
1609
1930
  }
1610
1931
  try {
@@ -1614,10 +1935,10 @@ let AwikiService = (() => {
1614
1935
  await this.sessionStore.signIn();
1615
1936
  this.signedOut = false;
1616
1937
  this.activeIdentityDid = undefined;
1938
+ this.pendingDeviceJoin = undefined;
1939
+ this.activeDeviceJoinSessionId = undefined;
1617
1940
  this.invalidateSummaries();
1618
1941
  this.publishSession({ status: 'unregistered' });
1619
- if (provider !== undefined && this.provider === provider)
1620
- void this.startListener(provider);
1621
1942
  return result;
1622
1943
  }
1623
1944
  catch {
@@ -1650,8 +1971,7 @@ let AwikiService = (() => {
1650
1971
  if (!alreadyActive) {
1651
1972
  const session = { status: 'active', identity };
1652
1973
  this.publishSession(session);
1653
- await provider.listenerStartup;
1654
- await this.startListener(provider);
1974
+ this.ensureProviderRuntime(provider);
1655
1975
  }
1656
1976
  return reconciled;
1657
1977
  }
@@ -1692,19 +2012,164 @@ let AwikiService = (() => {
1692
2012
  return false;
1693
2013
  }
1694
2014
  }
1695
- /** 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. */
1696
2157
  async activateRegisteredIdentity(identity) {
2158
+ this.pendingDeviceJoin = undefined;
2159
+ this.activeDeviceJoinSessionId = undefined;
1697
2160
  this.activeIdentityDid = identity.did;
1698
2161
  this.publishSession({ status: 'active', identity });
1699
2162
  const provider = this.provider;
1700
- if (provider !== undefined) {
1701
- await provider.listenerStartup;
1702
- await this.startListener(provider);
1703
- }
2163
+ if (provider !== undefined)
2164
+ this.ensureProviderRuntime(provider);
1704
2165
  }
1705
2166
  /** Invalidate cached session work and cancel every model request still owned by the old session. */
1706
2167
  invalidateSummaries() {
1707
2168
  this.sessionRevision += 1;
2169
+ this.requestRefs.clear();
2170
+ this.requestSessions.clear();
2171
+ this.deviceRefs.clear();
2172
+ this.deviceIds.clear();
1708
2173
  for (const controller of this.activeSummaryRequests)
1709
2174
  controller.abort();
1710
2175
  this.activeSummaryRequests.clear();
@@ -1806,158 +2271,194 @@ let AwikiService = (() => {
1806
2271
  this.sessionMutation = pending.then(() => undefined, () => undefined);
1807
2272
  return pending;
1808
2273
  }
1809
- /** Start one exact identity-bound listener, atomically releasing a failed startup. */
1810
- startListener(provider) {
1811
- if (!this.resolved.listenerEnabled
1812
- || provider.listener !== undefined
1813
- || provider.listenerRecoveryBlocked
1814
- || this.provider !== provider) {
1815
- return Promise.resolve();
1816
- }
1817
- if (provider.listenerStartup !== undefined)
1818
- return provider.listenerStartup;
1819
- if (provider.listenerCleanup !== undefined || provider.listenerRestartTimer !== undefined)
1820
- return Promise.resolve();
1821
- const workspaceContext = this.workspaceContext;
1822
- const source = provider.client.listener;
1823
- if (workspaceContext === undefined || source === undefined)
1824
- return Promise.resolve();
1825
- const generation = provider.listenerGeneration;
1826
- 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');
1827
2322
  const startup = (async () => {
1828
2323
  if (await this.isSignedOut())
1829
2324
  return;
1830
- if (await provider.client.getIdentity() === null)
2325
+ const identity = await provider.client.getIdentity();
2326
+ if (identity === null || !this.realtimeFenceMatches(provider, generation))
1831
2327
  return;
1832
- if (!this.listenerFenceMatches(provider, workspaceContext, generation))
1833
- return;
1834
- const agents = new DshAwikiListenerAgentRuntime(workspaceContext, this.resolved.listener.workspacePath);
1835
- const listener = new AwikiAgentListener(source, agents, this.resolved.listener, logger);
1836
- 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') {
1837
2354
  try {
1838
- await listener.start();
2355
+ provider.localDeviceJoinRequestCountAfterSync = (await provider.client.listLocalDeviceJoinRequests()).length;
1839
2356
  }
1840
- catch (error) {
1841
- if (provider.listener === listener)
1842
- delete provider.listener;
1843
- try {
1844
- await listener.dispose();
1845
- }
1846
- catch {
1847
- provider.listenerRecoveryBlocked = true;
1848
- }
1849
- if (!provider.listenerRecoveryBlocked && provider.listenerRestartAttempt > 0) {
1850
- this.scheduleListenerRestart(provider, workspaceContext, generation);
1851
- }
1852
- throw error;
2357
+ catch {
2358
+ this.ctx.logger('awiki-realtime').debug('AWiki realtime could not observe local device Join requests');
1853
2359
  }
1854
- if (!this.listenerFenceMatches(provider, workspaceContext, generation)) {
1855
- if (provider.listener === listener)
1856
- delete provider.listener;
1857
- try {
1858
- await listener.dispose();
1859
- }
1860
- catch (error) {
1861
- provider.listenerRecoveryBlocked = true;
1862
- throw error;
1863
- }
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))
1864
2388
  return;
1865
- }
1866
- provider.listenerStartedAt = Date.now();
1867
- void listener.whenTerminated().then((result) => {
1868
- if (result.kind === 'failed') {
1869
- this.releaseFailedListener(provider, listener, workspaceContext, generation, result.error);
1870
- }
1871
- });
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();
1872
2397
  })();
1873
2398
  const observed = startup
1874
2399
  .catch((error) => {
1875
- 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');
1876
2401
  })
1877
2402
  .finally(() => {
1878
- if (provider.listenerStartup === observed)
1879
- delete provider.listenerStartup;
2403
+ if (provider.agentConsumerStartup === observed)
2404
+ delete provider.agentConsumerStartup;
1880
2405
  });
1881
- provider.listenerStartup = observed;
1882
- return observed;
1883
- }
1884
- async stopListener(provider) {
1885
- provider.listenerGeneration += 1;
1886
- provider.listenerRestartAttempt = 0;
1887
- delete provider.listenerStartedAt;
1888
- const timer = provider.listenerRestartTimer;
1889
- if (timer !== undefined) {
1890
- delete provider.listenerRestartTimer;
1891
- clearTimeout(timer);
1892
- }
1893
- await provider.listenerStartup;
1894
- await provider.listenerCleanup;
1895
- const listener = provider.listener;
1896
- if (listener === undefined)
1897
- return;
1898
- delete provider.listener;
1899
- try {
1900
- await listener.dispose();
1901
- }
1902
- catch (error) {
1903
- provider.listenerRecoveryBlocked = true;
1904
- throw error;
1905
- }
2406
+ provider.agentConsumerStartup = observed;
1906
2407
  }
1907
- listenerFenceMatches(provider, workspaceContext, generation) {
2408
+ agentConsumerFenceMatches(provider, workspaceContext, identityDid, generation) {
1908
2409
  return this.provider === provider
1909
2410
  && this.workspaceContext === workspaceContext
1910
- && provider.listenerGeneration === generation;
2411
+ && this.activeIdentityDid === identityDid
2412
+ && provider.agentConsumerGeneration === generation;
1911
2413
  }
1912
- releaseFailedListener(provider, listener, workspaceContext, generation, error) {
1913
- if (provider.listener !== listener)
2414
+ async detachAgentConsumer(provider) {
2415
+ const consumer = provider.agentConsumer;
2416
+ if (consumer === undefined)
1914
2417
  return;
1915
- delete provider.listener;
1916
- const startedAt = provider.listenerStartedAt;
1917
- delete provider.listenerStartedAt;
1918
- if (startedAt !== undefined && Date.now() - startedAt >= LISTENER_STABLE_RESET_MS) {
1919
- provider.listenerRestartAttempt = 0;
1920
- }
1921
- const logger = this.ctx.logger('awiki-listener');
1922
- logger.warn('AWiki listener lifecycle failed: %s', error instanceof Error ? error.message : 'unknown failure');
1923
- let disposed = false;
1924
- const cleanup = listener.dispose()
1925
- .then(() => { disposed = true; }, (cleanupError) => {
1926
- provider.listenerRecoveryBlocked = true;
1927
- logger.warn('AWiki listener cleanup failed: %s', cleanupError instanceof Error ? cleanupError.message : 'unknown failure');
1928
- });
2418
+ delete provider.agentConsumer;
2419
+ delete provider.agentConsumerIdentityDid;
2420
+ const cleanup = consumer.dispose();
1929
2421
  const observed = cleanup.finally(() => {
1930
- if (provider.listenerCleanup === observed)
1931
- delete provider.listenerCleanup;
1932
- if (disposed)
1933
- this.scheduleListenerRestart(provider, workspaceContext, generation);
2422
+ if (provider.agentConsumerCleanup === observed)
2423
+ delete provider.agentConsumerCleanup;
1934
2424
  });
1935
- provider.listenerCleanup = observed;
1936
- }
1937
- scheduleListenerRestart(provider, workspaceContext, generation) {
1938
- if (provider.listenerRestartTimer !== undefined
1939
- || !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)
1940
2439
  return;
1941
- provider.listenerRestartAttempt = Math.min(provider.listenerRestartAttempt + 1, LISTENER_RESTART_MAX_ATTEMPT);
1942
- const exponent = provider.listenerRestartAttempt - 1;
1943
- const delay = Math.min(LISTENER_RESTART_BASE_DELAY_MS * 2 ** exponent, LISTENER_RESTART_MAX_DELAY_MS);
1944
- const timer = setTimeout(() => {
1945
- if (provider.listenerRestartTimer !== timer)
1946
- return;
1947
- delete provider.listenerRestartTimer;
1948
- if (!this.listenerFenceMatches(provider, workspaceContext, generation))
1949
- return;
1950
- void this.startListener(provider);
1951
- }, delay);
1952
- 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
+ ]);
1953
2449
  }
1954
2450
  /** Clear one exact provider slot before joining its one shared disposal. */
1955
2451
  disposeProvider(provider) {
1956
- if (this.provider === provider)
2452
+ if (this.provider === provider) {
1957
2453
  this.provider = undefined;
2454
+ this.pendingDeviceJoin = undefined;
2455
+ this.activeDeviceJoinSessionId = undefined;
2456
+ this.invalidateSummaries();
2457
+ }
1958
2458
  provider.disposal ??= (async () => {
1959
2459
  try {
1960
- await this.stopListener(provider);
2460
+ await provider.runtimeReplacement;
2461
+ await this.stopProviderRuntime(provider);
1961
2462
  }
1962
2463
  finally {
1963
2464
  await provider.client.dispose();