dsh-cindy-host 0.1.20

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 (82) hide show
  1. package/CHANGELOG.md +462 -0
  2. package/LICENSE +21 -0
  3. package/README.md +178 -0
  4. package/cordis.patch.yml +6 -0
  5. package/doc/architecture.md +34 -0
  6. package/doc/cindy-android-verify.md +362 -0
  7. package/doc/cindy-mobile-freeze-handoff.md +124 -0
  8. package/doc/cindy-phone-link.md +2902 -0
  9. package/doc/desktop-runtime-fix-20260930.md +69 -0
  10. package/doc/development-plan.md +19 -0
  11. package/doc/file-peer-apply-recovery-20260921.md +35 -0
  12. package/doc/file-peer-installed-hotfix-20260921.md +53 -0
  13. package/doc/file-peer-minimal-apply-20260921.md +78 -0
  14. package/doc/file-peer-original-contract-alignment-20260921.md +119 -0
  15. package/doc/file-peer-phone-export-observation-20260921.md +75 -0
  16. package/doc/file-peer-product-contract-audit-20260921.md +106 -0
  17. package/doc/file-peer-root-cause-20260921.md +53 -0
  18. package/doc/file-transfer.md +50 -0
  19. package/doc/host-performance-20260930.md +91 -0
  20. package/doc/neonabyss-apk-file-peer-diagnosis-20260921.md +87 -0
  21. package/doc/new-session-sync-fix-20260930.md +59 -0
  22. package/doc/patches/cindy-peer-silence-probe.patch +1112 -0
  23. package/doc/projection-read-probe.md +77 -0
  24. package/doc/publishing.md +259 -0
  25. package/doc/releasing.md +149 -0
  26. package/doc/research.md +24 -0
  27. package/doc/session-discovery-2026-09-20.md +67 -0
  28. package/lib/client.js +763 -0
  29. package/package.json +84 -0
  30. package/src/auth-session.js +189 -0
  31. package/src/authorization-policy.js +59 -0
  32. package/src/cindy-channels.js +1707 -0
  33. package/src/cindy-login-flow.js +159 -0
  34. package/src/cindy-login.js +80 -0
  35. package/src/cindy-message-row.js +432 -0
  36. package/src/cindy-session-row.js +171 -0
  37. package/src/console-sink.js +6 -0
  38. package/src/contracts.js +26 -0
  39. package/src/credential-store.js +128 -0
  40. package/src/demo.js +9 -0
  41. package/src/dsh-active-sessions.js +59 -0
  42. package/src/dsh-host-source.js +62 -0
  43. package/src/dsh-message-fold.js +608 -0
  44. package/src/dsh-plugin.js +1932 -0
  45. package/src/dsh-session-source.js +529 -0
  46. package/src/file-peer-protocol.js +7 -0
  47. package/src/file-peer-rtc.js +41 -0
  48. package/src/fixture-source.js +13 -0
  49. package/src/host-approvals.js +329 -0
  50. package/src/host-attachments.js +249 -0
  51. package/src/host-authorization.js +51 -0
  52. package/src/host-demo.js +3 -0
  53. package/src/host-export-jobs.js +356 -0
  54. package/src/host-export-owner.js +62 -0
  55. package/src/host-file-browser.js +400 -0
  56. package/src/host-file-ice.js +63 -0
  57. package/src/host-file-peer.js +289 -0
  58. package/src/host-file-reference.js +16 -0
  59. package/src/host-files.js +148 -0
  60. package/src/host-frame-budget.js +310 -0
  61. package/src/host-goals.js +284 -0
  62. package/src/host-history-view-limits.js +22 -0
  63. package/src/host-history-view.js +417 -0
  64. package/src/host-input-queue.js +505 -0
  65. package/src/host-media-fetch.js +511 -0
  66. package/src/host-media.js +678 -0
  67. package/src/host-models.js +231 -0
  68. package/src/host-palette.js +111 -0
  69. package/src/host-push-channels.js +37 -0
  70. package/src/host-reconnect.js +54 -0
  71. package/src/host-routes.js +266 -0
  72. package/src/host-settings.js +41 -0
  73. package/src/host-status.js +216 -0
  74. package/src/host-transfer-diagnostics.js +50 -0
  75. package/src/host.js +2497 -0
  76. package/src/login.js +31 -0
  77. package/src/logout.js +3 -0
  78. package/src/projection-read-model-sink.js +6 -0
  79. package/src/projection.js +43 -0
  80. package/src/session-flags.js +160 -0
  81. package/src/session-publisher.js +123 -0
  82. package/src/session-read-model.js +6 -0
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Map DSH's model catalog onto the capability payload the Cindy controller reads.
3
+ *
4
+ * The controller validates every field before it will use any of it
5
+ * (`normalizeMobileAgentCapabilities` in `maker-shared/agentCapabilities.ts`), and
6
+ * two of its names are counter-intuitive enough to be worth stating outright:
7
+ *
8
+ * - the display label is read from **`displayName`**, not from `label`;
9
+ * - plan mode is read from **`planMode.supported`**, an object, not from a
10
+ * boolean of the same meaning.
11
+ *
12
+ * Getting either wrong does not fail loudly: the field is simply dropped, the
13
+ * picker loses its list (or its label), and the surface looks like a Host that
14
+ * offers nothing.
15
+ */
16
+
17
+ /** One `MobileChoiceOption`, in the wire shape the controller validates. */
18
+ function toChoiceOption(id, name) {
19
+ if (typeof id !== 'string' || id === '') return null;
20
+ return {
21
+ id,
22
+ displayName: typeof name === 'string' && name !== '' ? name : id,
23
+ };
24
+ }
25
+
26
+ /**
27
+ * One `MobileModelOption`.
28
+ * @param model - a DSH `ModelCatalogModel`.
29
+ * @returns the wire option, or null when the model has no usable id.
30
+ */
31
+ function toModelOption(model) {
32
+ if (typeof model?.id !== 'string' || model.id === '') return null;
33
+ const reasons = Array.isArray(model?.reasoning?.efforts) ? model.reasoning.efforts : [];
34
+ const efforts = [];
35
+ const effortDisplayNames = {};
36
+ for (const effort of reasons) {
37
+ if (typeof effort?.id !== 'string' || effort.id === '') continue;
38
+ efforts.push(effort.id);
39
+ effortDisplayNames[effort.id] = typeof effort.name === 'string' && effort.name !== '' ? effort.name : effort.id;
40
+ }
41
+
42
+ return {
43
+ id: model.id,
44
+ displayName: typeof model.name === 'string' && model.name !== '' ? model.name : model.id,
45
+ ...(typeof model.description === 'string' && model.description !== '' ? { description: model.description } : {}),
46
+ efforts,
47
+ effortDisplayNames,
48
+ defaultEffort: typeof model?.reasoning?.defaultEffort === 'string' && model.reasoning.defaultEffort !== ''
49
+ ? model.reasoning.defaultEffort
50
+ : null,
51
+ // DSH has no per-model fast mode, so the controller must not offer one.
52
+ supportsFastMode: false,
53
+ };
54
+ }
55
+
56
+ /**
57
+ * Every model the catalog offers, in provider order.
58
+ * @param catalog - a DSH `ModelCatalog`.
59
+ * @returns the controller's `availableModels`.
60
+ */
61
+ export function toAvailableModels(catalog) {
62
+ const groups = Array.isArray(catalog?.groups) ? catalog.groups : [];
63
+ const models = [];
64
+ for (const group of groups) {
65
+ for (const model of Array.isArray(group?.models) ? group.models : []) {
66
+ const option = toModelOption(model);
67
+ if (option !== null) models.push(option);
68
+ }
69
+ }
70
+ return models;
71
+ }
72
+
73
+ /**
74
+ * The flat effort list the controller falls back to.
75
+ *
76
+ * DSH declares effort per model route, so this is the union — deduplicated in
77
+ * first-seen order, which keeps the list stable across catalog reads.
78
+ * @param availableModels - the options `toAvailableModels` produced.
79
+ * @returns the controller's `effortLevels`.
80
+ */
81
+ export function toEffortLevels(availableModels) {
82
+ const seen = new Map();
83
+ for (const model of availableModels) {
84
+ for (const effort of Array.isArray(model?.efforts) ? model.efforts : []) {
85
+ if (seen.has(effort)) continue;
86
+ const option = toChoiceOption(effort, model?.effortDisplayNames?.[effort]);
87
+ if (option !== null) seen.set(effort, option);
88
+ }
89
+ }
90
+ return [...seen.values()];
91
+ }
92
+
93
+ /**
94
+ * The capability payload for one agent kind.
95
+ * @param catalog - a DSH `ModelCatalog`, or undefined when none loaded.
96
+ * @returns the payload `maker:get-capabilities` answers with.
97
+ */
98
+ export function toAgentCapabilities(catalog) {
99
+ const availableModels = toAvailableModels(catalog);
100
+ return {
101
+ availableModels,
102
+ effortLevels: toEffortLevels(availableModels),
103
+ // This Host enforces no permission-mode vocabulary of its own; DSH's
104
+ // presets are not the controller's list, so the honest answer is empty.
105
+ permissionModes: [],
106
+ hasFastMode: false,
107
+ // Read as an object by the controller; a boolean here is silently ignored.
108
+ planMode: { supported: false },
109
+ supportsSessionAgentSwitch: false,
110
+ supportsModelWindowSwitchGuard: false,
111
+ };
112
+ }
113
+
114
+ /**
115
+ * The model id a session row should carry.
116
+ *
117
+ * The controller matches its current model with
118
+ * `availableModels.find(item => item.id === session.model)`, so a row whose
119
+ * `model` is a placeholder selects nothing and the picker shows no current model
120
+ * even when the catalog is correct.
121
+ * @param options - the catalog default and the session's recorded selection.
122
+ * @returns the model id, or null when the catalogue names none.
123
+ */
124
+ export function modelIdFor({ catalog, selection }) {
125
+ const selected = selection?.model;
126
+ if (typeof selected === 'string' && selected !== '') return selected;
127
+ const fallback = catalog?.default?.model;
128
+ return typeof fallback === 'string' && fallback !== '' ? fallback : null;
129
+ }
130
+
131
+ /** Cindy's permission vocabulary mapped onto DSH's sandbox presets. */
132
+ const CINDY_PERMISSION_BY_DSH = new Map([
133
+ ['read-only', { id: 'ask', displayName: 'Ask' }],
134
+ ['workspace-write', { id: 'acceptEdits', displayName: 'Accept edits' }],
135
+ ['danger-full-access', { id: 'bypassPermissions', displayName: 'Full access' }],
136
+ ]);
137
+ const DSH_PERMISSION_BY_CINDY = new Map([...CINDY_PERMISSION_BY_DSH].map(([dsh, cindy]) => [cindy.id, dsh]));
138
+
139
+ /** Translate one DSH permission preset to the public Cindy option id. */
140
+ export function toCindyPermissionMode(value) {
141
+ return CINDY_PERMISSION_BY_DSH.get(value)?.id ?? 'ask';
142
+ }
143
+
144
+ /** Translate one Cindy permission choice back to the DSH preset name. */
145
+ export function toDshPermissionMode(value) {
146
+ return DSH_PERMISSION_BY_CINDY.get(value) ?? null;
147
+ }
148
+
149
+ /** Advertise only DSH presets that have an exact Cindy control meaning. */
150
+ export function toPermissionOptions(names) {
151
+ const seen = new Set();
152
+ const options = [];
153
+ for (const name of Array.isArray(names) ? names : []) {
154
+ const option = CINDY_PERMISSION_BY_DSH.get(name);
155
+ if (option === undefined || seen.has(option.id)) continue;
156
+ seen.add(option.id);
157
+ options.push({ ...option });
158
+ }
159
+ return options;
160
+ }
161
+
162
+ /**
163
+ * Project DSH's provider-grouped catalog into Cindy desktop's ProviderView wire shape.
164
+ * Routing credentials never cross this boundary; every Cindy harness alias reaches the
165
+ * same DSH catalog, so each provider exposes the same models under all aliases.
166
+ */
167
+ export function toProviderList(catalog, agentKinds = []) {
168
+ const routable = new Set(Array.isArray(catalog?.routableProviders) ? catalog.routableProviders : []);
169
+ const agents = [...new Set((Array.isArray(agentKinds) ? agentKinds : []).filter((kind) => typeof kind === 'string' && kind !== ''))];
170
+ const providers = [];
171
+ for (const group of Array.isArray(catalog?.groups) ? catalog.groups : []) {
172
+ if (typeof group?.id !== 'string' || group.id === '') continue;
173
+ const models = [];
174
+ for (const model of Array.isArray(group.models) ? group.models : []) {
175
+ if (typeof model?.id !== 'string' || model.id === '') continue;
176
+ const contextWindow = Number(model.contextWindow);
177
+ if (!Number.isFinite(contextWindow) || contextWindow <= 0) continue;
178
+ const efforts = Array.isArray(model?.reasoning?.efforts)
179
+ ? model.reasoning.efforts.map((effort) => effort?.id).filter((id) => typeof id === 'string' && id !== '')
180
+ : [];
181
+ const defaultEffort = typeof model?.reasoning?.defaultEffort === 'string'
182
+ && efforts.includes(model.reasoning.defaultEffort)
183
+ ? model.reasoning.defaultEffort
184
+ : (efforts[0] ?? null);
185
+ models.push({
186
+ id: model.id,
187
+ name: typeof model.name === 'string' && model.name !== '' ? model.name : model.id,
188
+ contextWindow,
189
+ efforts,
190
+ defaultEffort,
191
+ supportsFastMode: false,
192
+ defaultEnabled: true,
193
+ });
194
+ }
195
+ if (models.length === 0) continue;
196
+ providers.push({
197
+ id: group.id,
198
+ name: typeof group.name === 'string' && group.name !== '' ? group.name : group.id,
199
+ agents,
200
+ connected: routable.has(group.id),
201
+ models: Object.fromEntries(agents.map((agent) => [agent, models.map((model) => ({ ...model }))])),
202
+ });
203
+ }
204
+ return { providers, providerOrder: providers.map((provider) => provider.id) };
205
+ }
206
+
207
+ /** The new-session defaults DSH can state without owning Cindy's draft preferences. */
208
+ export function toNewMakerDefaults(catalog) {
209
+ const providerId = typeof catalog?.default?.provider === 'string' && catalog.default.provider !== ''
210
+ ? catalog.default.provider
211
+ : null;
212
+ const modelId = typeof catalog?.default?.model === 'string' && catalog.default.model !== ''
213
+ ? catalog.default.model
214
+ : null;
215
+ if (modelId === null) return {};
216
+ const group = (Array.isArray(catalog?.groups) ? catalog.groups : []).find((entry) => entry?.id === providerId);
217
+ const model = (Array.isArray(group?.models) ? group.models : []).find((entry) => entry?.id === modelId);
218
+ const efforts = Array.isArray(model?.reasoning?.efforts)
219
+ ? model.reasoning.efforts.map((effort) => effort?.id).filter((id) => typeof id === 'string' && id !== '')
220
+ : [];
221
+ const effort = typeof model?.reasoning?.defaultEffort === 'string' && efforts.includes(model.reasoning.defaultEffort)
222
+ ? model.reasoning.defaultEffort
223
+ : (efforts[0] ?? undefined);
224
+ return {
225
+ model: modelId,
226
+ modelChosenByUser: false,
227
+ ...(effort === undefined ? {} : { effort }),
228
+ fastMode: false,
229
+ providerId,
230
+ };
231
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * The composer's `/` and `@` palettes, in the controller's vocabulary.
3
+ *
4
+ * Three channels feed them and they are easy to conflate:
5
+ *
6
+ * - `maker:list-agent-commands` — the agent's own slash commands, e.g. `/compact`;
7
+ * - `maker:list-agent-skills` — user-invocable skills, listed separately so the
8
+ * palette can offer them without pretending they are built-ins;
9
+ * - `maker:scan-at-resources` — the `@` menu, which lists paths, not commands.
10
+ *
11
+ * `maker:list-desktop-commands` is a fourth: commands that only the controlling
12
+ * desktop can run (Cindy's `/learn`). This Host owns none, and answering with an
13
+ * empty *success* is correct there — unlike the provider catalog, an empty list
14
+ * here means "nothing to offer", not "there is no such feature".
15
+ *
16
+ * Every list result is an envelope (`{ success, commands|skills|items }`), not a
17
+ * bare array: the controller reads the envelope, so an array would be dropped in
18
+ * silence.
19
+ */
20
+
21
+ /**
22
+ * Map one DSH skill source onto the controller's `'user' | 'skill'`.
23
+ *
24
+ * DSH names where a skill came from; the controller only asks whether the user
25
+ * put it there.
26
+ */
27
+ function skillSourceOf(source) {
28
+ return source === 'user-dsh' || source === 'user-agents' || source === 'custom' ? 'user' : 'skill';
29
+ }
30
+
31
+ /**
32
+ * Build the `maker:list-agent-skills` answer.
33
+ *
34
+ * Only skills a human may invoke are listed: `invocation.userInvocable` is DSH's
35
+ * own statement that a skill belongs in a human-facing catalog, and offering one
36
+ * that says no would put an entry in the palette that cannot run.
37
+ *
38
+ * @param summaries - `ctx.skills.list()` output.
39
+ * @returns the controller's `MobileAgentSkillListResult`.
40
+ */
41
+ export function toAgentSkills(summaries) {
42
+ const skills = [];
43
+ for (const skill of Array.isArray(summaries) ? summaries : []) {
44
+ if (typeof skill?.name !== 'string' || skill.name === '') continue;
45
+ if (skill?.invocation?.userInvocable !== true) continue;
46
+ skills.push({
47
+ kind: 'agent-skill',
48
+ name: skill.name,
49
+ description: typeof skill.description === 'string' ? skill.description : undefined,
50
+ source: skillSourceOf(skill.source),
51
+ });
52
+ }
53
+ return { success: true, skills };
54
+ }
55
+
56
+ /**
57
+ * Build the `maker:list-agent-commands` / `maker:list-desktop-commands` answer.
58
+ * @param descriptors - `ctx.commands.list(agent)` output, or nothing.
59
+ * @param kind - the controller's command kind for this channel.
60
+ * @returns the controller's command list result.
61
+ */
62
+ export function toAgentCommands(descriptors, kind = 'agent-builtin') {
63
+ const commands = [];
64
+ for (const command of Array.isArray(descriptors) ? descriptors : []) {
65
+ if (typeof command?.name !== 'string' || command.name === '') continue;
66
+ commands.push({
67
+ kind,
68
+ name: command.name,
69
+ description: typeof command.description === 'string' ? command.description : '',
70
+ });
71
+ }
72
+ return { success: true, commands };
73
+ }
74
+
75
+ /** Sort entries the way a palette reads: directories first, then by name. */
76
+ function compareEntries(a, b) {
77
+ if (a.type !== b.type) return a.type === 'dir' ? -1 : 1;
78
+ return a.name.localeCompare(b.name);
79
+ }
80
+
81
+ /**
82
+ * Build the `maker:scan-at-resources` answer for one working directory.
83
+ *
84
+ * The `@` menu lists what a message can reference, so this is a shallow listing
85
+ * of one directory rather than a tree: the controller re-scans with a deeper
86
+ * `relPath` when the user descends, and a tree would be both slower and larger
87
+ * than the palette can show.
88
+ *
89
+ * @param entries - directory entries: `{ name, kind }` where `kind` is `'dir'` or `'file'`.
90
+ * @param options - the base directory the entries are relative to, a cap, and a query filter.
91
+ * @returns the controller's `MobileAtResourceScanResult`.
92
+ */
93
+ export function toAtResources(entries, { workingDir = '', cap = 200, query = '' } = {}) {
94
+ const wanted = typeof query === 'string' ? query.trim().toLowerCase() : '';
95
+ const items = [];
96
+ for (const entry of Array.isArray(entries) ? entries : []) {
97
+ if (typeof entry?.name !== 'string' || entry.name === '') continue;
98
+ const isDir = entry.kind === 'dir';
99
+ if (wanted !== '' && !entry.name.toLowerCase().includes(wanted)) continue;
100
+ items.push({
101
+ type: isDir ? 'dir' : 'file',
102
+ name: entry.name,
103
+ // Relative to the working directory, which is the coordinate the
104
+ // controller sends back when the user picks the entry.
105
+ relPath: entry.name,
106
+ });
107
+ }
108
+ items.sort(compareEntries);
109
+ const limit = Number.isFinite(cap) && cap > 0 ? Math.floor(cap) : 200;
110
+ return { success: true, items: items.slice(0, limit), truncated: items.length > limit };
111
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Every push channel this Host sends, and the code that sends it.
3
+ *
4
+ * This list lives in `src` rather than in a tool because two callers need the same
5
+ * answer and had no way to share it: `tools/channel-audit.mjs` classifies the push
6
+ * side of the wire (what the phone handles, what this Host sends, what it declines
7
+ * and why), and `tools/acceptance.mjs` reports *coverage* — which of these channels a
8
+ * run actually produced. A second copy would drift, and the drift would be invisible:
9
+ * a channel added to the Host and forgotten here simply stops being audited.
10
+ *
11
+ * The value is the evidence for the claim, not a restatement of the code: each entry
12
+ * names the function that emits the frame, so a reader can check the pairing.
13
+ *
14
+ * @module dsh-cindy-host/host-push-channels
15
+ */
16
+
17
+ /** Push channel → the emitting path in this Host. */
18
+ export const HOST_PUSH_CHANNELS = new Map([
19
+ ['maker:event', 'host.js announceTurnRunning/announceTurnIdle (turn boundaries)'],
20
+ ['local-db:messages:created', 'host.js pushSessionMessage (live fold)'],
21
+ // The history view's own invalidation. The controller handles this channel *before* any
22
+ // store logic (`DeviceLinkContext`: `historyView?.invalidate(); return;`), and it is what
23
+ // refreshes a session the controller is already inside — without it a new message only
24
+ // appeared after re-entering the session (报:重新进入会话之后对话就出来了). The audit
25
+ // used to record this channel as deliberately unsent because the view it invalidates was
26
+ // the one this Host refused; the Host serves the view now, so the reason expired with it.
27
+ ['maker:history-view-changed', 'host.js pushSessionMessage (the view a session is showing)'],
28
+ ['maker:input:projection', 'host.js pushInputProjection (queue changes)'],
29
+ ['maker:goal:status-changed', 'host.js pushGoalStatus + applyControlFrame (goal projection)'],
30
+ ['maker:interaction-request', 'host.js askApproval/askUserQuestion'],
31
+ ['maker:interaction-dismissed', 'host.js onDismissed (answered, timed out, withdrawn)'],
32
+ ['local-db:sessions:created', 'session-publisher.publishLifecycle'],
33
+ ['local-db:sessions:patched', 'session-publisher.publishLifecycle'],
34
+ ['local-db:sessions:activity', 'session-publisher.publishActivity'],
35
+ ]);
36
+
37
+ export default HOST_PUSH_CHANNELS;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Reconnect policy for the relay socket.
3
+ *
4
+ * The reference is the Cindy device-link **client** (`packages/device-link/src/client.ts`),
5
+ * which never treats a lost socket as a terminal state: it keeps a `connecting` status
6
+ * and retries with exponential backoff, which is why a Cindy client "从来没有掉线过".
7
+ * This Host used to do the opposite — drop the socket, report `failed`, and wait for a
8
+ * person to press reconnect. On a phone-controlled Host that is a silent outage: the
9
+ * card says 断线 and the handset simply has no DSH in its device list until someone
10
+ * walks over to the desktop.
11
+ *
12
+ * The numbers are the client's defaults, so both ends of the same link recover on the
13
+ * same schedule:
14
+ *
15
+ * | constant | value | meaning |
16
+ * | --- | --- | --- |
17
+ * | `RECONNECT_BASE_MS` | 1s | first retry |
18
+ * | `RECONNECT_MAX_MS` | 30s | cap, so retries continue forever at ≤30s |
19
+ * | `RECONNECT_STABLE_RESET_MS` | 10s | stably online this long resets the ladder |
20
+ *
21
+ * One deliberate omission: the client's separate **congestion** ladder
22
+ * (`congestionBackoffBaseMs`/`congestionStableResetMs`) is driven by its reliable
23
+ * transport's backpressure signal (`transport-timeout-close-v1`), and this Host has no
24
+ * such layer — inventing a second ladder without that signal would only make the delay
25
+ * harder to predict.
26
+ *
27
+ * @module dsh-cindy-host/host-reconnect
28
+ */
29
+
30
+ /** First retry delay, matching the Cindy client. */
31
+ export const RECONNECT_BASE_MS = 1_000;
32
+ /** Longest gap between retries: retries continue indefinitely at this ceiling. */
33
+ export const RECONNECT_MAX_MS = 30_000;
34
+ /** How long a connection must hold before the attempt counter is forgotten. */
35
+ export const RECONNECT_STABLE_RESET_MS = 10_000;
36
+
37
+ /**
38
+ * Delay before the next attempt.
39
+ *
40
+ * Exponential from the base, capped, then multiplied by a **downward** jitter
41
+ * (0.7×–1.0×): many hosts that lose the same relay at the same moment must not come
42
+ * back in lockstep and re-congest it. Non-finite inputs are clamped rather than
43
+ * propagated — a `NaN` delay would silently become an immediate retry storm.
44
+ *
45
+ * @param options - `attempt` (0 for the first retry), the two bounds, and `random` in
46
+ * `[0, 1]` (injected so a test can pin the curve).
47
+ * @returns milliseconds to wait.
48
+ */
49
+ export function computeReconnectDelayMs({ attempt, reconnectBaseMs = RECONNECT_BASE_MS, reconnectMaxMs = RECONNECT_MAX_MS, random = 0 } = {}) {
50
+ const step = Number.isFinite(attempt) ? Math.max(0, Math.floor(attempt)) : 0;
51
+ const roll = Number.isFinite(random) ? Math.min(Math.max(random, 0), 1) : 0;
52
+ const base = Math.min(reconnectBaseMs * 2 ** step, reconnectMaxMs);
53
+ return Math.round(base * (0.7 + roll * 0.3));
54
+ }