@shendeguize/dsh-agent-sidecar 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +78 -11
  2. package/lib/client.js +4708 -2932
  3. package/lib/client.js.map +1 -1
  4. package/package.json +3 -1
  5. package/src/client/analysis/AnalysisPanel.tsx +19 -27
  6. package/src/client/analysis/analysis.module.css +36 -97
  7. package/src/client/board/Board.tsx +255 -48
  8. package/src/client/board/board.module.css +113 -92
  9. package/src/client/board/logic.ts +99 -21
  10. package/src/client/board/project-view-logic.ts +27 -34
  11. package/src/client/board/project-view.module.css +46 -83
  12. package/src/client/board/project-view.tsx +50 -8
  13. package/src/client/board/strings.ts +70 -85
  14. package/src/client/commands.ts +30 -15
  15. package/src/client/controller.ts +27 -7
  16. package/src/client/detail/SessionDetail.tsx +234 -37
  17. package/src/client/detail/detail.module.css +100 -153
  18. package/src/client/detail/logic.ts +220 -29
  19. package/src/client/detail/strings.ts +66 -77
  20. package/src/client/detail-glue.ts +33 -0
  21. package/src/client/detail-view.module.css +43 -35
  22. package/src/client/detail-view.tsx +138 -118
  23. package/src/client/dsh-tools/LineageTree.tsx +22 -13
  24. package/src/client/dsh-tools/SearchPanel.tsx +18 -11
  25. package/src/client/dsh-tools/dsh-tools.module.css +53 -140
  26. package/src/client/dsh-tools/strings.ts +42 -77
  27. package/src/client/index.ts +285 -124
  28. package/src/client/inject/InjectPanel.tsx +81 -61
  29. package/src/client/inject/inject.module.css +97 -197
  30. package/src/client/inject/logic.ts +38 -0
  31. package/src/client/lifecycle/handoff.ts +83 -0
  32. package/src/client/locales/en.ts +91 -4
  33. package/src/client/locales/host.ts +131 -0
  34. package/src/client/locales/index.ts +196 -8
  35. package/src/client/locales/react.ts +10 -0
  36. package/src/client/locales/view.ts +38 -0
  37. package/src/client/locales/zh.ts +200 -125
  38. package/src/client/mount.tsx +75 -41
  39. package/src/client/navigation/CenterOverlay.tsx +40 -0
  40. package/src/client/navigation/center-overlay.module.css +51 -0
  41. package/src/client/navigation/center.ts +45 -0
  42. package/src/client/navigation/modal-isolation.ts +152 -0
  43. package/src/client/navigation/modal-surface-anchor.ts +72 -0
  44. package/src/client/navigation/sidebar-entry.module.css +73 -0
  45. package/src/client/navigation/sidebar-entry.ts +309 -0
  46. package/src/client/primitives/StaticPill.tsx +21 -0
  47. package/src/client/settings-card.module.css +35 -119
  48. package/src/client/settings-card.tsx +31 -153
  49. package/src/client/settings-fields.tsx +131 -0
  50. package/src/client/sidebar/SidebarTab.tsx +156 -0
  51. package/src/client/sidebar/model.ts +65 -0
  52. package/src/client/sidebar/sidebar-tab.module.css +118 -0
  53. package/src/client/sidebar-tab.tsx +46 -320
  54. package/src/client/theme/agsc.module.css +31 -0
  55. package/src/client/theme/parts.ts +56 -0
  56. package/src/client/ui-integration.ts +86 -0
  57. package/src/client/widget.tsx +42 -16
  58. package/src/client/inject/overlay.module.css +0 -22
@@ -1,40 +1,30 @@
1
- /* Inject panel (design §5.1 view 3). Every color rides a dsh --dsw-alias-*
2
- * design token, no literal colors — same discipline as the settings card.
3
- * The confirm-injection button is the one danger affordance: eye-catching
4
- * (error-toned border/text, filled on hover) but restrained (no permanent
5
- * solid red block). */
6
-
7
1
  .panel {
8
- border: 1px solid var(--dsw-alias-border-l2);
9
- border-radius: 12px;
10
- background: var(--dsw-alias-bg-layer-2);
11
- max-width: 560px;
12
2
  display: flex;
13
3
  flex-direction: column;
4
+ max-width: 560px;
5
+ border: 1px solid var(--agsc-border-strong);
6
+ border-radius: var(--agsc-radius-card);
7
+ background: var(--agsc-bg);
14
8
  }
15
9
 
16
- /* ── header ────────────────────────────────────────────────────────────── */
17
-
18
10
  .header {
19
11
  display: flex;
20
12
  align-items: center;
21
13
  gap: 12px;
22
14
  padding: 14px 16px 10px;
23
- border-bottom: 1px solid var(--dsw-alias-border-l2);
15
+ border-bottom: 1px solid var(--agsc-border-strong);
24
16
  }
25
17
 
26
18
  .title {
27
19
  flex: 1;
28
20
  min-width: 0;
29
21
  margin: 0;
22
+ color: var(--agsc-fg);
30
23
  font-size: 15px;
31
24
  font-weight: 600;
32
25
  line-height: 1.4;
33
- color: var(--dsw-alias-label-primary);
34
26
  }
35
27
 
36
- /* ── body ──────────────────────────────────────────────────────────────── */
37
-
38
28
  .body {
39
29
  display: flex;
40
30
  flex-direction: column;
@@ -42,117 +32,120 @@
42
32
  padding: 12px 16px 14px;
43
33
  }
44
34
 
45
- .capabilityOff {
46
- margin: 0;
47
- border-radius: 8px;
48
- padding: 10px 12px;
49
- font-size: 13px;
50
- line-height: 1.6;
51
- background: var(--dsw-alias-bg-module-platform);
52
- color: var(--dsw-alias-label-secondary);
53
- }
54
-
55
- /* ── notices (editor banners after a failed/expired attempt) ───────────── */
56
-
35
+ .capabilityOff,
57
36
  .noticeWarn,
58
- .noticeError {
37
+ .noticeError,
38
+ .warnBar,
39
+ .auditNote,
40
+ .resultOk,
41
+ .resultFail,
42
+ .resultUnknown {
59
43
  margin: 0;
60
- border-radius: 8px;
61
44
  padding: 8px 10px;
45
+ border-radius: var(--agsc-radius-control);
46
+ background: var(--agsc-bg-raised);
47
+ color: var(--agsc-fg-secondary);
62
48
  font-size: 12px;
63
49
  line-height: 1.6;
64
- background: var(--dsw-alias-bg-module-platform);
65
- color: var(--dsw-alias-label-secondary);
66
50
  }
67
51
 
68
- .noticeError {
69
- border: 1px solid var(--dsw-alias-label-error);
70
- color: var(--dsw-alias-label-error);
52
+ .noticeWarn,
53
+ .warnBar {
54
+ border: 1px solid var(--agsc-warn);
55
+ color: var(--agsc-warn);
71
56
  }
72
57
 
73
- .noticeDetail {
74
- color: var(--dsw-alias-label-tertiary);
58
+ .noticeError,
59
+ .resultFail {
60
+ border: 1px solid var(--agsc-err);
61
+ color: var(--agsc-err);
75
62
  }
76
63
 
77
- /* ── target row ────────────────────────────────────────────────────────── */
64
+ .noticeDetail,
65
+ .noTarget,
66
+ .modeHint,
67
+ .planKey,
68
+ .observedNote,
69
+ .resultDetail {
70
+ color: var(--agsc-fg-dimmed);
71
+ }
78
72
 
79
- .targetRow {
73
+ .targetRow,
74
+ .planRow {
80
75
  display: flex;
81
76
  align-items: baseline;
82
77
  gap: 10px;
83
78
  min-width: 0;
84
79
  }
85
80
 
86
- .noTarget {
81
+ .noTarget,
82
+ .planKey,
83
+ .modeHint,
84
+ .resultDetail {
87
85
  font-size: 12px;
88
86
  line-height: 1.5;
89
- color: var(--dsw-alias-label-tertiary);
90
87
  }
91
88
 
92
89
  .agentTag {
93
90
  flex: none;
94
- border-radius: 999px;
95
- padding: 1px 8px;
96
- font-size: 11px;
97
- line-height: 17px;
98
- font-weight: 500;
99
- background: var(--dsw-alias-bg-module-platform);
100
- color: var(--dsw-alias-label-secondary);
101
91
  }
102
92
 
103
93
  .planTitle {
104
94
  min-width: 0;
105
95
  overflow: hidden;
106
- text-overflow: ellipsis;
107
- white-space: nowrap;
96
+ color: var(--agsc-fg);
108
97
  font-size: 13px;
109
98
  line-height: 1.5;
110
- color: var(--dsw-alias-label-primary);
99
+ text-overflow: ellipsis;
100
+ white-space: nowrap;
111
101
  }
112
102
 
113
- /* ── editor field ──────────────────────────────────────────────────────── */
114
-
115
- .field {
103
+ .field,
104
+ .modeText,
105
+ .planPreview {
116
106
  display: flex;
117
107
  flex-direction: column;
108
+ }
109
+
110
+ .field {
118
111
  gap: 6px;
119
112
  }
120
113
 
121
- .label {
114
+ .label,
115
+ .modeLabel {
116
+ color: var(--agsc-fg);
122
117
  font-size: 13px;
123
118
  line-height: 1.5;
124
- color: var(--dsw-alias-label-primary);
125
119
  }
126
120
 
127
121
  .textarea {
128
122
  appearance: none;
129
- resize: vertical;
130
123
  min-height: 96px;
131
- border: 1px solid var(--dsw-alias-border-l2);
132
- border-radius: 8px;
133
- background: var(--dsw-alias-bg-layer-3);
124
+ resize: vertical;
134
125
  padding: 8px 10px;
126
+ border: 1px solid var(--agsc-border-strong);
127
+ border-radius: var(--agsc-radius-control);
128
+ background: var(--agsc-bg-raised);
129
+ color: var(--agsc-fg);
135
130
  font: inherit;
136
131
  font-size: 13px;
137
132
  line-height: 1.6;
138
- color: var(--dsw-alias-label-primary);
139
133
  }
140
134
 
141
135
  .textarea:focus-visible {
136
+ border-color: var(--agsc-accent);
142
137
  outline: none;
143
- border-color: var(--dsw-alias-brand-primary);
144
138
  }
145
139
 
146
140
  .textarea::placeholder {
147
- color: var(--dsw-alias-label-tertiary);
141
+ color: var(--agsc-fg-dimmed);
148
142
  }
149
143
 
150
144
  .textarea:disabled {
151
- opacity: 0.5;
152
145
  cursor: default;
146
+ opacity: 0.5;
153
147
  }
154
148
 
155
- /* Byte counter: thin progress track + numeric readout. */
156
149
  .byteRow {
157
150
  display: flex;
158
151
  align-items: center;
@@ -162,52 +155,49 @@
162
155
  .byteBar {
163
156
  flex: 1;
164
157
  height: 3px;
165
- border-radius: 2px;
166
- background: var(--dsw-alias-bg-module-platform);
167
158
  overflow: hidden;
159
+ border-radius: 2px;
160
+ background: var(--agsc-border);
168
161
  }
169
162
 
170
163
  .byteFill {
171
164
  height: 100%;
172
165
  border-radius: 2px;
173
- background: var(--dsw-alias-brand-primary);
166
+ background: var(--agsc-accent);
174
167
  transition: width .12s;
175
168
  }
176
169
 
177
- /* Modifier applied ALONGSIDE .byteFill (no `composes`; see settings card). */
178
170
  .byteFillOver {
179
- background: var(--dsw-alias-label-error);
171
+ background: var(--agsc-err);
180
172
  }
181
173
 
182
174
  .byteText,
183
175
  .byteTextOver {
184
176
  flex: none;
177
+ color: var(--agsc-fg-dimmed);
185
178
  font-size: 11px;
186
- line-height: 1.5;
187
179
  font-variant-numeric: tabular-nums;
188
- color: var(--dsw-alias-label-tertiary);
180
+ line-height: 1.5;
189
181
  }
190
182
 
191
- .byteTextOver {
192
- color: var(--dsw-alias-label-error);
183
+ .byteTextOver,
184
+ .invalid {
185
+ color: var(--agsc-err);
193
186
  }
194
187
 
195
188
  .invalid {
196
189
  margin: 0;
197
190
  font-size: 12px;
198
191
  line-height: 1.5;
199
- color: var(--dsw-alias-label-error);
200
192
  }
201
193
 
202
- /* ── mode radios ───────────────────────────────────────────────────────── */
203
-
204
194
  .modes {
205
- margin: 0;
206
- border: 0;
207
- padding: 0;
208
195
  display: flex;
209
196
  flex-direction: column;
210
197
  gap: 8px;
198
+ margin: 0;
199
+ padding: 0;
200
+ border: 0;
211
201
  }
212
202
 
213
203
  .modeOption {
@@ -218,10 +208,10 @@
218
208
  }
219
209
 
220
210
  .modeOption input {
221
- accent-color: var(--dsw-alias-brand-primary);
222
211
  width: 14px;
223
212
  height: 14px;
224
213
  margin: 3px 0 0;
214
+ accent-color: var(--agsc-accent);
225
215
  cursor: pointer;
226
216
  }
227
217
 
@@ -230,71 +220,26 @@
230
220
  }
231
221
 
232
222
  .modeText {
233
- display: flex;
234
- flex-direction: column;
235
223
  gap: 2px;
236
224
  min-width: 0;
237
225
  }
238
226
 
239
- .modeLabel {
240
- font-size: 13px;
241
- line-height: 1.5;
242
- color: var(--dsw-alias-label-primary);
243
- }
244
-
245
- .modeHint {
246
- font-size: 12px;
247
- line-height: 1.5;
248
- color: var(--dsw-alias-label-tertiary);
249
- }
250
-
251
- /* ── warnings & audit note ─────────────────────────────────────────────── */
252
-
253
- .warnBar {
254
- margin: 0;
255
- border: 1px solid var(--dsw-alias-label-error);
256
- border-radius: 8px;
257
- padding: 8px 10px;
258
- font-size: 12px;
259
- line-height: 1.6;
260
- background: var(--dsw-alias-bg-module-platform);
261
- color: var(--dsw-alias-label-error);
262
- }
263
-
264
227
  .auditNote {
265
- margin: 0;
266
- border-radius: 8px;
267
228
  padding: 8px 10px;
268
- font-size: 12px;
269
- line-height: 1.6;
270
- background: var(--dsw-alias-bg-module-platform);
271
- color: var(--dsw-alias-label-secondary);
272
229
  }
273
230
 
274
- /* ── confirm plan ──────────────────────────────────────────────────────── */
275
-
276
231
  .planBox {
277
- border: 1px solid var(--dsw-alias-border-l2);
278
- border-radius: 8px;
279
- background: var(--dsw-alias-bg-layer-3);
280
- padding: 10px 12px;
281
232
  display: flex;
282
233
  flex-direction: column;
283
234
  gap: 6px;
284
- }
285
-
286
- .planRow {
287
- display: flex;
288
- align-items: baseline;
289
- gap: 10px;
290
- min-width: 0;
235
+ padding: 10px 12px;
236
+ border: 1px solid var(--agsc-border-strong);
237
+ border-radius: var(--agsc-radius-control);
238
+ background: var(--agsc-bg-raised);
291
239
  }
292
240
 
293
241
  .planKey {
294
242
  flex: none;
295
- font-size: 12px;
296
- line-height: 1.5;
297
- color: var(--dsw-alias-label-tertiary);
298
243
  }
299
244
 
300
245
  .planValue {
@@ -302,85 +247,67 @@
302
247
  align-items: baseline;
303
248
  gap: 8px;
304
249
  min-width: 0;
250
+ color: var(--agsc-fg);
305
251
  font-size: 13px;
306
252
  line-height: 1.5;
307
- color: var(--dsw-alias-label-primary);
308
253
  }
309
254
 
310
255
  .observedNote {
311
256
  margin: 0;
312
257
  font-size: 11px;
313
258
  line-height: 1.5;
314
- color: var(--dsw-alias-label-tertiary);
315
259
  }
316
260
 
317
261
  .planPreview {
318
- display: flex;
319
- flex-direction: column;
320
262
  gap: 4px;
321
263
  min-width: 0;
322
264
  }
323
265
 
324
266
  .preview {
325
- margin: 0;
326
- border: 1px solid var(--dsw-alias-border-l2);
327
- border-radius: 6px;
328
- background: var(--dsw-alias-bg-layer-2);
329
- padding: 8px 10px;
330
267
  max-height: 120px;
331
268
  overflow: auto;
269
+ margin: 0;
270
+ padding: 8px 10px;
271
+ border: 1px solid var(--agsc-border-strong);
272
+ border-radius: 6px;
273
+ background: var(--agsc-bg);
274
+ color: var(--agsc-fg-secondary);
332
275
  font-size: 12px;
333
276
  line-height: 1.6;
334
277
  white-space: pre-wrap;
335
278
  word-break: break-word;
336
- color: var(--dsw-alias-label-secondary);
337
279
  }
338
280
 
339
281
  .countdown {
340
282
  margin: 0;
283
+ color: var(--agsc-fg-secondary);
341
284
  font-size: 12px;
342
- line-height: 1.5;
343
285
  font-variant-numeric: tabular-nums;
344
- color: var(--dsw-alias-label-secondary);
286
+ line-height: 1.5;
345
287
  }
346
288
 
347
- /* ── results ───────────────────────────────────────────────────────────── */
348
-
349
289
  .resultOk,
350
290
  .resultFail,
351
291
  .resultUnknown {
352
- margin: 0;
353
- border-radius: 8px;
354
292
  padding: 10px 12px;
293
+ color: var(--agsc-fg);
355
294
  font-size: 13px;
356
- line-height: 1.6;
357
- background: var(--dsw-alias-bg-module-platform);
358
- color: var(--dsw-alias-label-primary);
359
295
  }
360
296
 
361
297
  .resultOk {
362
- border: 1px solid var(--dsw-alias-brand-primary);
363
- }
364
-
365
- .resultFail {
366
- border: 1px solid var(--dsw-alias-label-error);
367
- color: var(--dsw-alias-label-error);
298
+ border: 1px solid var(--agsc-ok);
368
299
  }
369
300
 
370
301
  .resultUnknown {
371
- border: 1px solid var(--dsw-alias-label-dimmed);
372
- color: var(--dsw-alias-label-secondary);
302
+ border: 1px solid var(--agsc-fg-dimmed);
303
+ color: var(--agsc-fg-secondary);
373
304
  }
374
305
 
375
306
  .resultDetail {
376
307
  margin: 0;
377
- font-size: 12px;
378
308
  line-height: 1.6;
379
- color: var(--dsw-alias-label-tertiary);
380
309
  }
381
310
 
382
- /* ── footer buttons ────────────────────────────────────────────────────── */
383
-
384
311
  .footer {
385
312
  display: flex;
386
313
  align-items: center;
@@ -389,58 +316,31 @@
389
316
  padding-top: 4px;
390
317
  }
391
318
 
392
- .btn,
393
- .btnPrimary,
394
319
  .btnDanger {
395
320
  appearance: none;
396
- border: 1px solid transparent;
397
- border-radius: 8px;
398
321
  padding: 5px 14px;
322
+ border: 1px solid var(--agsc-err);
323
+ border-radius: var(--agsc-radius-control);
324
+ background: transparent;
325
+ color: var(--agsc-err);
399
326
  font: inherit;
400
327
  font-size: 13px;
328
+ font-weight: 600;
401
329
  line-height: 1.5;
402
330
  cursor: pointer;
403
331
  }
404
332
 
405
- .btn {
406
- border-color: var(--dsw-alias-border-l2);
407
- background: none;
408
- color: var(--dsw-alias-label-secondary);
409
- }
410
-
411
- .btn:hover:not(:disabled) {
412
- color: var(--dsw-alias-label-primary);
413
- border-color: var(--dsw-alias-label-dimmed);
414
- }
415
-
416
- .btnPrimary {
417
- background: var(--dsw-alias-label-primary);
418
- color: var(--dsw-alias-bg-layer-3);
419
- }
420
-
421
- /* The danger action: outlined error tone at rest, fills on hover. */
422
- .btnDanger {
423
- border-color: var(--dsw-alias-label-error);
424
- background: none;
425
- color: var(--dsw-alias-label-error);
426
- font-weight: 600;
427
- }
428
-
429
333
  .btnDanger:hover:not(:disabled) {
430
- background: var(--dsw-alias-label-error);
431
- color: var(--dsw-alias-bg-layer-3);
334
+ background: var(--agsc-err);
335
+ color: var(--agsc-bg-raised);
432
336
  }
433
337
 
434
- .btn:disabled,
435
- .btnPrimary:disabled,
436
338
  .btnDanger:disabled {
437
- opacity: 0.4;
438
339
  cursor: default;
340
+ opacity: 0.4;
439
341
  }
440
342
 
441
- .btn:focus-visible,
442
- .btnPrimary:focus-visible,
443
343
  .btnDanger:focus-visible {
444
- outline: 2px solid var(--dsw-alias-brand-primary);
344
+ outline: 2px solid var(--agsc-accent);
445
345
  outline-offset: 1px;
446
346
  }
@@ -418,6 +418,33 @@ export function deriveEditorGate(input: {
418
418
  return { canPrepare: true, block: null }
419
419
  }
420
420
 
421
+ // ---------------------------------------------------------------------------
422
+ // Keyboard affordances (UX-10).
423
+ // ---------------------------------------------------------------------------
424
+
425
+ export type PanelKeyIntent = 'close' | 'prepare' | null
426
+
427
+ /**
428
+ * Map a keydown to a panel intent: Escape closes in every phase;
429
+ * Cmd/Ctrl+Enter submits the PREPARE step only (editor phase with a valid
430
+ * message). The confirm phase deliberately binds nothing — executing the
431
+ * injection stays an explicit click (two-phase discipline, §5.3), so no
432
+ * keyboard path can shortcut the confirmation.
433
+ */
434
+ export function classifyPanelKey(input: {
435
+ key: string
436
+ metaKey: boolean
437
+ ctrlKey: boolean
438
+ phase: InjectPhase
439
+ canPrepare: boolean
440
+ }): PanelKeyIntent {
441
+ if (input.key === 'Escape') return 'close'
442
+ if (input.key === 'Enter' && (input.metaKey || input.ctrlKey)) {
443
+ return input.phase === 'idle' && input.canPrepare ? 'prepare' : null
444
+ }
445
+ return null
446
+ }
447
+
421
448
  // ---------------------------------------------------------------------------
422
449
  // Result actions (S6: unknown is terminal).
423
450
  // ---------------------------------------------------------------------------
@@ -437,6 +464,17 @@ export function resultActions(outcome: InjectOutcome): ResultActions {
437
464
  }
438
465
  }
439
466
 
467
+ /**
468
+ * True when an execute response reports the message actually reached the
469
+ * target (UX-05 observation loop): a delivered outcome — including an
470
+ * idempotent replay of one — never an error envelope. failed/unknown must
471
+ * NOT trigger observation aids: unknown is a locked terminal state (S6)
472
+ * and any follow-up activity could read as "retrying is fine".
473
+ */
474
+ export function isDeliveredResult(value: InjectResultView | ApiErrorLike): boolean {
475
+ return !isApiErrorLike(value) && value.outcome === 'delivered'
476
+ }
477
+
440
478
  // ---------------------------------------------------------------------------
441
479
  // Copy mapping (keys live in the T2.3 locale table, inject.* domain).
442
480
  // ---------------------------------------------------------------------------
@@ -0,0 +1,83 @@
1
+ export type ResourceDisposer = () => void
2
+
3
+ /** Narrow clock port for deterministic handoff tests. */
4
+ export interface HandoffScheduler {
5
+ setTimeout(callback: () => void, delayMs: number): unknown
6
+ clearTimeout(handle: unknown): void
7
+ }
8
+
9
+ export interface HandoffOptions {
10
+ isCollision(error: unknown): boolean
11
+ onError(error: unknown): void
12
+ onTimeout(): void
13
+ scheduler?: HandoffScheduler
14
+ }
15
+
16
+ const DEFAULT_DELAYS_MS = [8, 16, 32, 64, 128, 256, 496] as const
17
+
18
+ const defaultScheduler: HandoffScheduler = {
19
+ setTimeout: (callback, delayMs) => globalThis.setTimeout(callback, delayMs),
20
+ clearTimeout: (handle) => globalThis.clearTimeout(handle as ReturnType<typeof setTimeout>),
21
+ }
22
+
23
+ /**
24
+ * Acquire a registry resource now, or briefly wait for an overlapping old
25
+ * fiber to release it. The returned lease owns both waiting and acquisition.
26
+ */
27
+ export function acquireWithHandoff(
28
+ register: () => ResourceDisposer | undefined,
29
+ options: HandoffOptions,
30
+ ): ResourceDisposer {
31
+ const scheduler = options.scheduler ?? defaultScheduler
32
+ let stopped = false
33
+ let attempts = 0
34
+ let delayIndex = 0
35
+ let timer: unknown
36
+ let acquired: ResourceDisposer | undefined
37
+
38
+ const finishError = (error: unknown): void => {
39
+ stopped = true
40
+ options.onError(error)
41
+ }
42
+
43
+ const attempt = (): void => {
44
+ if (stopped) return
45
+ timer = undefined
46
+ attempts += 1
47
+ try {
48
+ acquired = register()
49
+ } catch (error) {
50
+ if (!options.isCollision(error)) {
51
+ finishError(error)
52
+ return
53
+ }
54
+ }
55
+ if (acquired !== undefined) return
56
+ if (attempts >= 8 || delayIndex >= DEFAULT_DELAYS_MS.length) {
57
+ stopped = true
58
+ options.onTimeout()
59
+ return
60
+ }
61
+ const delay = DEFAULT_DELAYS_MS[delayIndex++]
62
+ try {
63
+ timer = scheduler.setTimeout(attempt, delay!)
64
+ } catch (error) {
65
+ finishError(error)
66
+ }
67
+ }
68
+
69
+ attempt()
70
+ return () => {
71
+ if (stopped) return
72
+ stopped = true
73
+ if (timer !== undefined) scheduler.clearTimeout(timer)
74
+ const dispose = acquired
75
+ acquired = undefined
76
+ dispose?.()
77
+ }
78
+ }
79
+
80
+ /** Only known duplicate-registry diagnostics are eligible for handoff. */
81
+ export function isRegistrationCollision(error: unknown): boolean {
82
+ return error instanceof Error && /\b(?:duplicate|already registered)\b/i.test(error.message)
83
+ }