@shendeguize/dsh-agent-sidecar 0.1.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 (68) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +167 -0
  3. package/cordis.patch.yml +10 -0
  4. package/lib/client.js +8062 -0
  5. package/lib/client.js.map +1 -0
  6. package/lib/index.d.ts +396 -0
  7. package/lib/index.js +4166 -0
  8. package/package.json +101 -0
  9. package/src/analysis.ts +782 -0
  10. package/src/bridge.ts +841 -0
  11. package/src/client/analysis/AnalysisPanel.tsx +191 -0
  12. package/src/client/analysis/analysis.module.css +183 -0
  13. package/src/client/analysis-glue.ts +331 -0
  14. package/src/client/api.ts +380 -0
  15. package/src/client/board/Board.tsx +214 -0
  16. package/src/client/board/board.module.css +302 -0
  17. package/src/client/board/logic.ts +556 -0
  18. package/src/client/board/project-view-logic.ts +361 -0
  19. package/src/client/board/project-view.module.css +307 -0
  20. package/src/client/board/project-view.tsx +189 -0
  21. package/src/client/board/strings.ts +112 -0
  22. package/src/client/commands.ts +484 -0
  23. package/src/client/controller.ts +360 -0
  24. package/src/client/css-modules.d.ts +11 -0
  25. package/src/client/detail/SessionDetail.tsx +270 -0
  26. package/src/client/detail/detail.module.css +433 -0
  27. package/src/client/detail/logic.ts +779 -0
  28. package/src/client/detail/strings.ts +98 -0
  29. package/src/client/detail/transport.ts +175 -0
  30. package/src/client/detail-glue.ts +397 -0
  31. package/src/client/detail-view.module.css +79 -0
  32. package/src/client/detail-view.tsx +233 -0
  33. package/src/client/dsh-tools/LineageTree.tsx +210 -0
  34. package/src/client/dsh-tools/SearchPanel.tsx +169 -0
  35. package/src/client/dsh-tools/dsh-tools.module.css +374 -0
  36. package/src/client/dsh-tools/logic.ts +596 -0
  37. package/src/client/dsh-tools/strings.ts +90 -0
  38. package/src/client/index.ts +315 -0
  39. package/src/client/inject/InjectPanel.tsx +482 -0
  40. package/src/client/inject/inject.module.css +446 -0
  41. package/src/client/inject/logic.ts +516 -0
  42. package/src/client/inject/overlay.module.css +22 -0
  43. package/src/client/inject-glue.ts +171 -0
  44. package/src/client/locales/command.ts +48 -0
  45. package/src/client/locales/en.ts +385 -0
  46. package/src/client/locales/index.ts +123 -0
  47. package/src/client/locales/zh.ts +402 -0
  48. package/src/client/m3-transport.ts +151 -0
  49. package/src/client/mount.tsx +307 -0
  50. package/src/client/project-glue.ts +134 -0
  51. package/src/client/search-glue.ts +143 -0
  52. package/src/client/settings-card.module.css +359 -0
  53. package/src/client/settings-card.tsx +565 -0
  54. package/src/client/settings-glue.ts +130 -0
  55. package/src/client/sidebar-tab.tsx +494 -0
  56. package/src/client/sse.ts +366 -0
  57. package/src/client/widget.tsx +80 -0
  58. package/src/config.ts +193 -0
  59. package/src/dsh-inject.ts +240 -0
  60. package/src/fusion.ts +988 -0
  61. package/src/guard.ts +274 -0
  62. package/src/index.ts +950 -0
  63. package/src/inject-gateway.ts +574 -0
  64. package/src/routes.ts +1133 -0
  65. package/src/send-cli.ts +340 -0
  66. package/src/session-store.ts +184 -0
  67. package/src/skills-provider.ts +293 -0
  68. package/src/supervisor.ts +463 -0
@@ -0,0 +1,565 @@
1
+ /**
2
+ * Agent Sidecar settings card (browser half, T2.3).
3
+ *
4
+ * MECHANISM (source-audit conclusion): the dsh settings pane does NOT
5
+ * auto-render a form from the host Config schema. The plugin-configuration
6
+ * tab dispatches the keyed `settings.plugin.item` slot per Host-served
7
+ * settings namespace and "a card draws its own internals; the tab only
8
+ * decides which namespaces to dispatch and stacks what comes back"
9
+ * (harness `packages/client/ui-settings-plugins/src/client/slot-contract.ts`;
10
+ * unclaimed namespaces render nothing per the adding-a-settings-card
11
+ * cookbook). This card therefore carries the form UI for the key items of
12
+ * the host Config (src/config.ts): daemon.policy/backoffLimit,
13
+ * sidecar.command/runtimeDir, stream.reconcileActiveMs/IdleMs,
14
+ * inject.enabled/defaultMode, analysis.enabled, ui.timeWindowHours/showDead,
15
+ * skill.provide — plus the daemon status/retry row and the injection safety
16
+ * note the design doc (§4.a/§5.3/§6) puts on the settings surface.
17
+ *
18
+ * WIRING CONTRACT (T2.4): the card is fully controlled and presentational.
19
+ * `values` is the staged draft owned by the wiring controller; every edit
20
+ * reports through `onChange(field, value)` and the controller must echo the
21
+ * staged value back through `values` (the shipped ui-settings-plugins cards
22
+ * follow the same staged-edit split). Save/discard/retry are plain
23
+ * callbacks. Registration into `settings.plugin.item` (keyed by the Host
24
+ * settings namespace) also belongs to the wiring half — this module exports
25
+ * the component and its props contract only.
26
+ *
27
+ * The card renders as an `<li>` because the plugin-configuration tab stacks
28
+ * cards in a list (shipped PluginCard precedent). Chrome (disclosure
29
+ * header, unsaved pill, save/discard footer) mirrors the shipped card so
30
+ * the sidecar card reads native next to first-party ones.
31
+ */
32
+
33
+ import { useEffect, useId, useState } from 'react'
34
+ import type { ReactNode } from 'react'
35
+ import { t as defaultT } from './locales/index.ts'
36
+ import type { SidecarLocaleKey } from './locales/index.ts'
37
+ import css from './settings-card.module.css'
38
+
39
+ /**
40
+ * Daemon supervisor state as surfaced to the browser. Mirrors
41
+ * `SupervisorState` in src/supervisor.ts verbatim; restated here because
42
+ * host and client are separate TS programs (tsconfig.host excludes
43
+ * src/client and vice versa) and the client bundle must not import host
44
+ * modules.
45
+ */
46
+ export type SidecarDaemonState =
47
+ | 'probe'
48
+ | 'adopted'
49
+ | 'defer'
50
+ | 'reprobe'
51
+ | 'hosting'
52
+ | 'hosted'
53
+ | 'backoff'
54
+ | 'failed'
55
+
56
+ /** Daemon status block input (shape matches the supervisor's ping-derived view). */
57
+ export interface SidecarDaemonStatus {
58
+ state: SidecarDaemonState
59
+ /** Daemon pid, when a ping succeeded. */
60
+ pid?: number
61
+ /** Daemon version, when a ping succeeded. */
62
+ version?: string
63
+ }
64
+
65
+ /**
66
+ * The staged form values — one flat field per key item of the host Config
67
+ * schema (grouping mirrors src/config.ts).
68
+ */
69
+ export interface SettingsCardValues {
70
+ /** daemon.policy */
71
+ daemonPolicy: 'adopt-or-host' | 'adopt-only' | 'off'
72
+ /** daemon.backoffLimit (integer ≥ 1) */
73
+ daemonBackoffLimit: number
74
+ /**
75
+ * sidecar.command, displayed and edited as one whitespace-joined line;
76
+ * argv splitting/joining is the wiring controller's concern.
77
+ */
78
+ sidecarCommand: string
79
+ /** sidecar.runtimeDir ('' = default ~/.agent_sidecar) */
80
+ sidecarRuntimeDir: string
81
+ /** stream.reconcileActiveMs (integer ≥ 100) */
82
+ streamReconcileActiveMs: number
83
+ /** stream.reconcileIdleMs (integer ≥ 100) */
84
+ streamReconcileIdleMs: number
85
+ /** inject.enabled */
86
+ injectEnabled: boolean
87
+ /** inject.defaultMode */
88
+ injectDefaultMode: 'queue' | 'steer'
89
+ /** analysis.enabled */
90
+ analysisEnabled: boolean
91
+ /**
92
+ * analysis.provider / analysis.model ('' = reuse the host default model).
93
+ * Carried through the staged values for save round-trip fidelity (a group
94
+ * write is a COMPLETE analysis object — dropping them here would wipe an
95
+ * explicit configuration); not yet rendered as form fields.
96
+ */
97
+ analysisProvider: string
98
+ analysisModel: string
99
+ /** ui.timeWindowHours (integer ≥ 1) */
100
+ uiTimeWindowHours: number
101
+ /** ui.showDead */
102
+ uiShowDead: boolean
103
+ /** skill.provide */
104
+ skillProvide: boolean
105
+ }
106
+
107
+ /**
108
+ * Translate signature the card consumes: the module-local `t` by default,
109
+ * or (same shape) a slot-injected / ctx.locale-bound seat supplied by the
110
+ * wiring half.
111
+ */
112
+ export type SettingsTranslate = (
113
+ key: SidecarLocaleKey,
114
+ params?: Record<string, unknown>,
115
+ ) => string
116
+
117
+ export interface SettingsCardProps {
118
+ /** Staged form values (owned and echoed back by the wiring controller). */
119
+ values: SettingsCardValues
120
+ /** Report one staged edit. */
121
+ onChange: <K extends keyof SettingsCardValues>(
122
+ field: K,
123
+ value: SettingsCardValues[K],
124
+ ) => void
125
+ /** Write every staged edit. */
126
+ onSave: () => void
127
+ /** Drop every staged edit. */
128
+ onDiscard: () => void
129
+ /** False disables every control and shows the read-only note. */
130
+ writable: boolean
131
+ /** Whether unsaved edits stand. */
132
+ dirty: boolean
133
+ /** Whether a save is in flight. */
134
+ saving: boolean
135
+ /** Whether the last save failed. */
136
+ saveFailed?: boolean
137
+ /** Daemon status block; omitted hides the row. */
138
+ daemon?: SidecarDaemonStatus
139
+ /** Retry hosting after the supervisor tripped `failed`. */
140
+ onDaemonRetry?: () => void
141
+ /** Documentation link target; omitted hides the link. */
142
+ docsUrl?: string
143
+ /** Locale seat override; defaults to the module-local table. */
144
+ t?: SettingsTranslate
145
+ }
146
+
147
+ const DAEMON_STATE_KEY: Record<SidecarDaemonState, SidecarLocaleKey> = {
148
+ 'probe': 'settings.daemonStateProbe',
149
+ 'adopted': 'settings.daemonStateAdopted',
150
+ 'defer': 'settings.daemonStateDefer',
151
+ 'reprobe': 'settings.daemonStateReprobe',
152
+ 'hosting': 'settings.daemonStateHosting',
153
+ 'hosted': 'settings.daemonStateHosted',
154
+ 'backoff': 'settings.daemonStateBackoff',
155
+ 'failed': 'settings.daemonStateFailed',
156
+ }
157
+
158
+ /** Dot classes per daemon state: healthy / transitional / tripped. */
159
+ function statusDotClass(state: SidecarDaemonState): string {
160
+ if (state === 'adopted' || state === 'hosted') return `${css['statusDot']} ${css['statusOk']}`
161
+ if (state === 'failed') return `${css['statusDot']} ${css['statusError']}`
162
+ return css['statusDot'] ?? ''
163
+ }
164
+
165
+ interface SectionProps {
166
+ title: string
167
+ children: ReactNode
168
+ }
169
+
170
+ function Section(props: SectionProps): ReactNode {
171
+ return (
172
+ <section className={css['section']}>
173
+ <h3 className={css['sectionTitle']}>{props.title}</h3>
174
+ {props.children}
175
+ </section>
176
+ )
177
+ }
178
+
179
+ interface SelectFieldProps {
180
+ label: string
181
+ hint: string
182
+ value: string
183
+ options: readonly { value: string, label: string }[]
184
+ disabled: boolean
185
+ onCommit: (value: string) => void
186
+ }
187
+
188
+ function SelectField(props: SelectFieldProps): ReactNode {
189
+ const id = useId()
190
+ return (
191
+ <div className={css['field']}>
192
+ <label className={css['label']} htmlFor={id}>{props.label}</label>
193
+ <select
194
+ id={id}
195
+ className={css['select']}
196
+ value={props.value}
197
+ disabled={props.disabled}
198
+ onChange={(event) => { props.onCommit(event.target.value) }}
199
+ >
200
+ {props.options.map(option => (
201
+ <option key={option.value} value={option.value}>{option.label}</option>
202
+ ))}
203
+ </select>
204
+ <p className={css['hint']}>{props.hint}</p>
205
+ </div>
206
+ )
207
+ }
208
+
209
+ interface ToggleFieldProps {
210
+ label: string
211
+ hint: string
212
+ checked: boolean
213
+ disabled: boolean
214
+ onCommit: (checked: boolean) => void
215
+ }
216
+
217
+ function ToggleField(props: ToggleFieldProps): ReactNode {
218
+ return (
219
+ <div className={css['field']}>
220
+ <label className={css['toggleRow']}>
221
+ <input
222
+ type="checkbox"
223
+ checked={props.checked}
224
+ disabled={props.disabled}
225
+ onChange={(event) => { props.onCommit(event.target.checked) }}
226
+ />
227
+ <span className={css['label']}>{props.label}</span>
228
+ </label>
229
+ <p className={css['hint']}>{props.hint}</p>
230
+ </div>
231
+ )
232
+ }
233
+
234
+ interface TextFieldProps {
235
+ label: string
236
+ hint: string
237
+ value: string
238
+ placeholder?: string
239
+ disabled: boolean
240
+ onCommit: (value: string) => void
241
+ }
242
+
243
+ /** Text field with a local draft so typing survives a non-echoing beat. */
244
+ function TextField(props: TextFieldProps): ReactNode {
245
+ const id = useId()
246
+ const [draft, setDraft] = useState(props.value)
247
+ useEffect(() => { setDraft(props.value) }, [props.value])
248
+ return (
249
+ <div className={css['field']}>
250
+ <label className={css['label']} htmlFor={id}>{props.label}</label>
251
+ <input
252
+ id={id}
253
+ className={css['input']}
254
+ type="text"
255
+ value={draft}
256
+ placeholder={props.placeholder ?? ''}
257
+ disabled={props.disabled}
258
+ onChange={(event) => {
259
+ setDraft(event.target.value)
260
+ props.onCommit(event.target.value)
261
+ }}
262
+ />
263
+ <p className={css['hint']}>{props.hint}</p>
264
+ </div>
265
+ )
266
+ }
267
+
268
+ interface NumberFieldProps {
269
+ label: string
270
+ hint: string
271
+ /** Copy shown while the draft is not an acceptable integer. */
272
+ invalidHint: string
273
+ /** Schema lower bound (host Config schemastery `.min()`). */
274
+ min: number
275
+ value: number
276
+ disabled: boolean
277
+ onCommit: (value: number) => void
278
+ }
279
+
280
+ /**
281
+ * Integer field with a local draft: invalid intermediate text (empty,
282
+ * non-numeric, below the schema minimum) shows the invalid hint and commits
283
+ * nothing, so the staged value can never leave the schema's domain.
284
+ */
285
+ function NumberField(props: NumberFieldProps): ReactNode {
286
+ const id = useId()
287
+ const [draft, setDraft] = useState(String(props.value))
288
+ const [invalid, setInvalid] = useState(false)
289
+ useEffect(() => {
290
+ setDraft(String(props.value))
291
+ setInvalid(false)
292
+ }, [props.value])
293
+ return (
294
+ <div className={css['field']}>
295
+ <label className={css['label']} htmlFor={id}>{props.label}</label>
296
+ <input
297
+ id={id}
298
+ className={invalid ? `${css['input']} ${css['inputInvalid']}` : css['input']}
299
+ type="text"
300
+ inputMode="numeric"
301
+ value={draft}
302
+ disabled={props.disabled}
303
+ {...invalid ? { 'aria-invalid': true } : {}}
304
+ onChange={(event) => {
305
+ const text = event.target.value
306
+ setDraft(text)
307
+ const parsed = Number(text)
308
+ const acceptable = text.trim() !== '' && Number.isInteger(parsed) && parsed >= props.min
309
+ setInvalid(!acceptable)
310
+ if (acceptable && parsed !== props.value) props.onCommit(parsed)
311
+ }}
312
+ />
313
+ {invalid
314
+ ? <p className={css['invalidHint']} role="alert">{props.invalidHint}</p>
315
+ : <p className={css['hint']}>{props.hint}</p>}
316
+ </div>
317
+ )
318
+ }
319
+
320
+ /**
321
+ * Render the Agent Sidecar settings card.
322
+ * @param props - staged values, form state, and the wiring callbacks.
323
+ * @returns the card.
324
+ */
325
+ export function SettingsCard(props: SettingsCardProps): ReactNode {
326
+ const [open, setOpen] = useState(false)
327
+ const t = props.t ?? defaultT
328
+ const { values } = props
329
+ const disabled = !props.writable || props.saving
330
+ const title = t('settings.cardTitle')
331
+
332
+ const daemonNote = props.daemon?.state === 'defer'
333
+ ? t('settings.daemonDeferNote')
334
+ : props.daemon?.state === 'failed'
335
+ ? t('settings.daemonFailedNote')
336
+ : undefined
337
+
338
+ return (
339
+ <li className={`${css['card']} ${open ? css['cardOpen'] : ''}`}>
340
+ <button
341
+ type="button"
342
+ className={css['header']}
343
+ aria-expanded={open}
344
+ aria-label={`${t(open ? 'settings.collapse' : 'settings.expand')}: ${title}`}
345
+ onClick={() => { setOpen(!open) }}
346
+ >
347
+ <span className={css['headText']}>
348
+ <span className={css['name']}>{title}</span>
349
+ <span className={css['description']}>{t('settings.cardDescription')}</span>
350
+ </span>
351
+ {props.dirty ? <span className={css['pending']}>{t('settings.unsaved')}</span> : null}
352
+ <span className={`${css['chevron']} ${open ? css['chevronOpen'] : ''}`} aria-hidden />
353
+ </button>
354
+ {open
355
+ ? (
356
+ <div className={css['body']}>
357
+ {!props.writable
358
+ ? <p className={css['readOnly']} role="status">{t('settings.readOnly')}</p>
359
+ : null}
360
+
361
+ <Section title={t('settings.sectionDaemon')}>
362
+ {props.daemon
363
+ ? (
364
+ <div className={css['field']}>
365
+ <span className={css['label']}>{t('settings.daemonStatusLabel')}</span>
366
+ <div className={css['statusRow']}>
367
+ <span className={statusDotClass(props.daemon.state)} aria-hidden />
368
+ <span className={css['statusText']}>
369
+ {t(DAEMON_STATE_KEY[props.daemon.state])}
370
+ </span>
371
+ {props.daemon.pid !== undefined && props.daemon.version !== undefined
372
+ ? (
373
+ <span className={css['statusMeta']}>
374
+ {t('settings.daemonPidVersion', {
375
+ pid: props.daemon.pid,
376
+ version: props.daemon.version,
377
+ })}
378
+ </span>
379
+ )
380
+ : null}
381
+ {props.daemon.state === 'failed' && props.onDaemonRetry !== undefined
382
+ ? (
383
+ <button
384
+ type="button"
385
+ className={css['retry']}
386
+ onClick={props.onDaemonRetry}
387
+ >
388
+ {t('settings.daemonRetry')}
389
+ </button>
390
+ )
391
+ : null}
392
+ </div>
393
+ {daemonNote !== undefined
394
+ ? <p className={css['note']}>{daemonNote}</p>
395
+ : null}
396
+ </div>
397
+ )
398
+ : null}
399
+ <SelectField
400
+ label={t('settings.daemonPolicyLabel')}
401
+ hint={t('settings.daemonPolicyHint')}
402
+ value={values.daemonPolicy}
403
+ disabled={disabled}
404
+ options={[
405
+ { value: 'adopt-or-host', label: t('settings.daemonPolicyAdoptOrHost') },
406
+ { value: 'adopt-only', label: t('settings.daemonPolicyAdoptOnly') },
407
+ { value: 'off', label: t('settings.daemonPolicyOff') },
408
+ ]}
409
+ onCommit={(value) => {
410
+ props.onChange('daemonPolicy', value as SettingsCardValues['daemonPolicy'])
411
+ }}
412
+ />
413
+ <NumberField
414
+ label={t('settings.daemonBackoffLimitLabel')}
415
+ hint={t('settings.daemonBackoffLimitHint')}
416
+ invalidHint={t('settings.invalidNumber', { min: 1 })}
417
+ min={1}
418
+ value={values.daemonBackoffLimit}
419
+ disabled={disabled}
420
+ onCommit={(value) => { props.onChange('daemonBackoffLimit', value) }}
421
+ />
422
+ </Section>
423
+
424
+ <Section title={t('settings.sectionSidecar')}>
425
+ <TextField
426
+ label={t('settings.sidecarCommandLabel')}
427
+ hint={t('settings.sidecarCommandHint')}
428
+ value={values.sidecarCommand}
429
+ placeholder="agent-sidecar"
430
+ disabled={disabled}
431
+ onCommit={(value) => { props.onChange('sidecarCommand', value) }}
432
+ />
433
+ <TextField
434
+ label={t('settings.sidecarRuntimeDirLabel')}
435
+ hint={t('settings.sidecarRuntimeDirHint')}
436
+ value={values.sidecarRuntimeDir}
437
+ placeholder="~/.agent_sidecar"
438
+ disabled={disabled}
439
+ onCommit={(value) => { props.onChange('sidecarRuntimeDir', value) }}
440
+ />
441
+ </Section>
442
+
443
+ <Section title={t('settings.sectionStream')}>
444
+ <NumberField
445
+ label={t('settings.streamActiveMsLabel')}
446
+ hint={t('settings.streamActiveMsHint')}
447
+ invalidHint={t('settings.invalidNumber', { min: 100 })}
448
+ min={100}
449
+ value={values.streamReconcileActiveMs}
450
+ disabled={disabled}
451
+ onCommit={(value) => { props.onChange('streamReconcileActiveMs', value) }}
452
+ />
453
+ <NumberField
454
+ label={t('settings.streamIdleMsLabel')}
455
+ hint={t('settings.streamIdleMsHint')}
456
+ invalidHint={t('settings.invalidNumber', { min: 100 })}
457
+ min={100}
458
+ value={values.streamReconcileIdleMs}
459
+ disabled={disabled}
460
+ onCommit={(value) => { props.onChange('streamReconcileIdleMs', value) }}
461
+ />
462
+ </Section>
463
+
464
+ <Section title={t('settings.sectionInject')}>
465
+ <p className={css['note']}>{t('settings.injectSafetyNote')}</p>
466
+ <ToggleField
467
+ label={t('settings.injectEnabledLabel')}
468
+ hint={t('settings.injectEnabledHint')}
469
+ checked={values.injectEnabled}
470
+ disabled={disabled}
471
+ onCommit={(checked) => { props.onChange('injectEnabled', checked) }}
472
+ />
473
+ <SelectField
474
+ label={t('settings.injectDefaultModeLabel')}
475
+ hint={t('settings.injectDefaultModeHint')}
476
+ value={values.injectDefaultMode}
477
+ disabled={disabled}
478
+ options={[
479
+ { value: 'queue', label: t('settings.injectModeQueue') },
480
+ { value: 'steer', label: t('settings.injectModeSteer') },
481
+ ]}
482
+ onCommit={(value) => {
483
+ props.onChange('injectDefaultMode', value as SettingsCardValues['injectDefaultMode'])
484
+ }}
485
+ />
486
+ </Section>
487
+
488
+ <Section title={t('settings.sectionAnalysis')}>
489
+ <ToggleField
490
+ label={t('settings.analysisEnabledLabel')}
491
+ hint={t('settings.analysisEnabledHint')}
492
+ checked={values.analysisEnabled}
493
+ disabled={disabled}
494
+ onCommit={(checked) => { props.onChange('analysisEnabled', checked) }}
495
+ />
496
+ </Section>
497
+
498
+ <Section title={t('settings.sectionUi')}>
499
+ <NumberField
500
+ label={t('settings.uiTimeWindowHoursLabel')}
501
+ hint={t('settings.uiTimeWindowHoursHint')}
502
+ invalidHint={t('settings.invalidNumber', { min: 1 })}
503
+ min={1}
504
+ value={values.uiTimeWindowHours}
505
+ disabled={disabled}
506
+ onCommit={(value) => { props.onChange('uiTimeWindowHours', value) }}
507
+ />
508
+ <ToggleField
509
+ label={t('settings.uiShowDeadLabel')}
510
+ hint={t('settings.uiShowDeadHint')}
511
+ checked={values.uiShowDead}
512
+ disabled={disabled}
513
+ onCommit={(checked) => { props.onChange('uiShowDead', checked) }}
514
+ />
515
+ </Section>
516
+
517
+ <Section title={t('settings.sectionSkill')}>
518
+ <ToggleField
519
+ label={t('settings.skillProvideLabel')}
520
+ hint={t('settings.skillProvideHint')}
521
+ checked={values.skillProvide}
522
+ disabled={disabled}
523
+ onCommit={(checked) => { props.onChange('skillProvide', checked) }}
524
+ />
525
+ </Section>
526
+
527
+ <div className={css['footer']}>
528
+ {props.docsUrl !== undefined
529
+ ? (
530
+ <a
531
+ className={css['docs']}
532
+ href={props.docsUrl}
533
+ target="_blank"
534
+ rel="noreferrer"
535
+ >
536
+ {t('settings.docsLink')}
537
+ </a>
538
+ )
539
+ : null}
540
+ {props.saveFailed === true
541
+ ? <p className={css['failed']} role="status">{t('settings.saveFailed')}</p>
542
+ : <span className={css['spacer']} />}
543
+ <button
544
+ type="button"
545
+ className={css['discard']}
546
+ disabled={!props.dirty || props.saving}
547
+ onClick={props.onDiscard}
548
+ >
549
+ {t('settings.discard')}
550
+ </button>
551
+ <button
552
+ type="button"
553
+ className={css['save']}
554
+ disabled={!props.dirty || props.saving || !props.writable}
555
+ onClick={props.onSave}
556
+ >
557
+ {t(props.saving ? 'settings.saving' : 'settings.save')}
558
+ </button>
559
+ </div>
560
+ </div>
561
+ )
562
+ : null}
563
+ </li>
564
+ )
565
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Pure conversion layer between the host Config wire shape and the settings
3
+ * card's flat form values (T2.4 wiring glue; no React, no I/O, node-testable).
4
+ *
5
+ * `SidecarConfigView` hand-mirrors the host `Config` interface
6
+ * (src/config.ts) the same way api.ts mirrors the route wire types: host and
7
+ * client are separate TS programs, so the client restates the shape it reads
8
+ * off the settings scope (`ctx.settingsScope.bind({namespace}).getSnapshot()
9
+ * .value` carries the schema-resolved section, which IS this shape).
10
+ *
11
+ * Write granularity: the browser settings scope's `set(field, value)` writes
12
+ * exactly one top-level path segment (`{op:'set', path:[field]}` — verified
13
+ * against harness ui-settings settings-scope.ts), so saving works per
14
+ * top-level GROUP: {@link diffGroups} emits one complete group object per
15
+ * group whose staged values differ from the resolved ones. Writing a whole
16
+ * group marks all its fields user-overridden — the settings service treats
17
+ * presence in the user layer as the override marker regardless of value,
18
+ * which is its documented semantics, not a distortion.
19
+ *
20
+ * @module
21
+ */
22
+
23
+ import type { SettingsCardValues } from './settings-card.tsx'
24
+
25
+ /** Mirror of the host `Config` (src/config.ts); see the module doc. */
26
+ export interface SidecarConfigView {
27
+ daemon: { policy: 'adopt-or-host' | 'adopt-only' | 'off'; backoffLimit: number }
28
+ sidecar: { command: string[]; runtimeDir: string }
29
+ stream: { reconcileActiveMs: number; reconcileIdleMs: number }
30
+ inject: { enabled: boolean; defaultMode: 'queue' | 'steer' }
31
+ analysis: { enabled: boolean; provider: string; model: string }
32
+ ui: { timeWindowHours: number; showDead: boolean }
33
+ skill: { provide: boolean }
34
+ }
35
+
36
+ /** Schema defaults of src/config.ts (fallback while the scope is not ready). */
37
+ export const DEFAULT_CONFIG_VIEW: SidecarConfigView = {
38
+ daemon: { policy: 'adopt-or-host', backoffLimit: 5 },
39
+ sidecar: { command: ['agent-sidecar'], runtimeDir: '' },
40
+ stream: { reconcileActiveMs: 2000, reconcileIdleMs: 10000 },
41
+ inject: { enabled: false, defaultMode: 'queue' },
42
+ analysis: { enabled: false, provider: '', model: '' },
43
+ ui: { timeWindowHours: 24, showDead: false },
44
+ skill: { provide: true },
45
+ }
46
+
47
+ /** Whitespace-join an argv for the card's single-line command field. */
48
+ export function joinCommand(argv: readonly string[]): string {
49
+ return argv.join(' ')
50
+ }
51
+
52
+ /** Split the card's command line back into argv (whitespace, empty dropped). */
53
+ export function splitCommand(line: string): string[] {
54
+ return line.split(/\s+/).filter((part) => part !== '')
55
+ }
56
+
57
+ /** Config wire shape → the card's flat staged-form values. */
58
+ export function configToValues(config: SidecarConfigView): SettingsCardValues {
59
+ return {
60
+ daemonPolicy: config.daemon.policy,
61
+ daemonBackoffLimit: config.daemon.backoffLimit,
62
+ sidecarCommand: joinCommand(config.sidecar.command),
63
+ sidecarRuntimeDir: config.sidecar.runtimeDir,
64
+ streamReconcileActiveMs: config.stream.reconcileActiveMs,
65
+ streamReconcileIdleMs: config.stream.reconcileIdleMs,
66
+ injectEnabled: config.inject.enabled,
67
+ injectDefaultMode: config.inject.defaultMode,
68
+ analysisEnabled: config.analysis.enabled,
69
+ analysisProvider: config.analysis.provider,
70
+ analysisModel: config.analysis.model,
71
+ uiTimeWindowHours: config.ui.timeWindowHours,
72
+ uiShowDead: config.ui.showDead,
73
+ skillProvide: config.skill.provide,
74
+ }
75
+ }
76
+
77
+ /** Flat card values → the grouped config wire shape. */
78
+ export function valuesToConfigView(values: SettingsCardValues): SidecarConfigView {
79
+ return {
80
+ daemon: { policy: values.daemonPolicy, backoffLimit: values.daemonBackoffLimit },
81
+ sidecar: {
82
+ command: splitCommand(values.sidecarCommand),
83
+ runtimeDir: values.sidecarRuntimeDir,
84
+ },
85
+ stream: {
86
+ reconcileActiveMs: values.streamReconcileActiveMs,
87
+ reconcileIdleMs: values.streamReconcileIdleMs,
88
+ },
89
+ inject: { enabled: values.injectEnabled, defaultMode: values.injectDefaultMode },
90
+ analysis: {
91
+ enabled: values.analysisEnabled,
92
+ provider: values.analysisProvider,
93
+ model: values.analysisModel,
94
+ },
95
+ ui: { timeWindowHours: values.uiTimeWindowHours, showDead: values.uiShowDead },
96
+ skill: { provide: values.skillProvide },
97
+ }
98
+ }
99
+
100
+ /** Field-wise equality over the flat card values (argv compared as joined text). */
101
+ export function cardValuesEqual(a: SettingsCardValues, b: SettingsCardValues): boolean {
102
+ return (Object.keys(a) as Array<keyof SettingsCardValues>).every(
103
+ (key) => a[key] === b[key],
104
+ )
105
+ }
106
+
107
+ /** One pending settings write: a complete top-level group object. */
108
+ export interface GroupPatch {
109
+ group: keyof SidecarConfigView
110
+ patch: Record<string, unknown>
111
+ }
112
+
113
+ /**
114
+ * Diff two card-value sets into per-group writes (see module doc for why the
115
+ * write unit is a whole group). Order is the Config declaration order.
116
+ */
117
+ export function diffGroups(
118
+ current: SettingsCardValues,
119
+ target: SettingsCardValues,
120
+ ): GroupPatch[] {
121
+ const from = valuesToConfigView(current)
122
+ const to = valuesToConfigView(target)
123
+ const patches: GroupPatch[] = []
124
+ for (const group of Object.keys(to) as Array<keyof SidecarConfigView>) {
125
+ if (JSON.stringify(from[group]) !== JSON.stringify(to[group])) {
126
+ patches.push({ group, patch: { ...to[group] } })
127
+ }
128
+ }
129
+ return patches
130
+ }