@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.
- package/LICENSE +21 -0
- package/README.md +167 -0
- package/cordis.patch.yml +10 -0
- package/lib/client.js +8062 -0
- package/lib/client.js.map +1 -0
- package/lib/index.d.ts +396 -0
- package/lib/index.js +4166 -0
- package/package.json +101 -0
- package/src/analysis.ts +782 -0
- package/src/bridge.ts +841 -0
- package/src/client/analysis/AnalysisPanel.tsx +191 -0
- package/src/client/analysis/analysis.module.css +183 -0
- package/src/client/analysis-glue.ts +331 -0
- package/src/client/api.ts +380 -0
- package/src/client/board/Board.tsx +214 -0
- package/src/client/board/board.module.css +302 -0
- package/src/client/board/logic.ts +556 -0
- package/src/client/board/project-view-logic.ts +361 -0
- package/src/client/board/project-view.module.css +307 -0
- package/src/client/board/project-view.tsx +189 -0
- package/src/client/board/strings.ts +112 -0
- package/src/client/commands.ts +484 -0
- package/src/client/controller.ts +360 -0
- package/src/client/css-modules.d.ts +11 -0
- package/src/client/detail/SessionDetail.tsx +270 -0
- package/src/client/detail/detail.module.css +433 -0
- package/src/client/detail/logic.ts +779 -0
- package/src/client/detail/strings.ts +98 -0
- package/src/client/detail/transport.ts +175 -0
- package/src/client/detail-glue.ts +397 -0
- package/src/client/detail-view.module.css +79 -0
- package/src/client/detail-view.tsx +233 -0
- package/src/client/dsh-tools/LineageTree.tsx +210 -0
- package/src/client/dsh-tools/SearchPanel.tsx +169 -0
- package/src/client/dsh-tools/dsh-tools.module.css +374 -0
- package/src/client/dsh-tools/logic.ts +596 -0
- package/src/client/dsh-tools/strings.ts +90 -0
- package/src/client/index.ts +315 -0
- package/src/client/inject/InjectPanel.tsx +482 -0
- package/src/client/inject/inject.module.css +446 -0
- package/src/client/inject/logic.ts +516 -0
- package/src/client/inject/overlay.module.css +22 -0
- package/src/client/inject-glue.ts +171 -0
- package/src/client/locales/command.ts +48 -0
- package/src/client/locales/en.ts +385 -0
- package/src/client/locales/index.ts +123 -0
- package/src/client/locales/zh.ts +402 -0
- package/src/client/m3-transport.ts +151 -0
- package/src/client/mount.tsx +307 -0
- package/src/client/project-glue.ts +134 -0
- package/src/client/search-glue.ts +143 -0
- package/src/client/settings-card.module.css +359 -0
- package/src/client/settings-card.tsx +565 -0
- package/src/client/settings-glue.ts +130 -0
- package/src/client/sidebar-tab.tsx +494 -0
- package/src/client/sse.ts +366 -0
- package/src/client/widget.tsx +80 -0
- package/src/config.ts +193 -0
- package/src/dsh-inject.ts +240 -0
- package/src/fusion.ts +988 -0
- package/src/guard.ts +274 -0
- package/src/index.ts +950 -0
- package/src/inject-gateway.ts +574 -0
- package/src/routes.ts +1133 -0
- package/src/send-cli.ts +340 -0
- package/src/session-store.ts +184 -0
- package/src/skills-provider.ts +293 -0
- 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
|
+
}
|