@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,366 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* StateStream — the browser half's live data feed (design §5.3 / ADR-3).
|
|
3
|
+
*
|
|
4
|
+
* One class, two modes, one UI-facing surface (onSnapshot/onStatus):
|
|
5
|
+
*
|
|
6
|
+
* - 'sse' (main channel): EventSource on `GET <prefix>/stream`. Every
|
|
7
|
+
* `state` event carries a full StateSnapshot (routes.ts pushes full
|
|
8
|
+
* snapshots; heartbeats are comment frames the browser never surfaces).
|
|
9
|
+
* On top of EventSource's native auto-reconnect, consecutive errors
|
|
10
|
+
* beyond the threshold — or a CLOSED readyState — degrade the stream:
|
|
11
|
+
* the instance is torn down and manually rebuilt on a 5s→30s capped
|
|
12
|
+
* exponential backoff. `degraded` is a visible status, never a circuit
|
|
13
|
+
* break; a successful reconnect resets the ladder.
|
|
14
|
+
* - 'poll' (settings fallback, stream.mode=poll): pollFn (defaults to
|
|
15
|
+
* api.fetchState) on a dual cadence — 3s while the latest snapshot has
|
|
16
|
+
* a `working` session, 15s otherwise. `visible()` returning false
|
|
17
|
+
* pauses fetching (ticks keep running as cheap visibility checks), and
|
|
18
|
+
* {@link StateStream.pollNow} gives the visibilitychange handler an
|
|
19
|
+
* immediate resume fetch.
|
|
20
|
+
*
|
|
21
|
+
* The status vocabulary is unified across modes:
|
|
22
|
+
* 'connecting' | 'open' | 'degraded'. stop() is terminal and tears
|
|
23
|
+
* everything down: timers, the EventSource, the in-flight poll fetch
|
|
24
|
+
* (aborted), and both listener sets.
|
|
25
|
+
*
|
|
26
|
+
* Pure data layer: no React, no slots SDK, injectable primitives only.
|
|
27
|
+
*
|
|
28
|
+
* @module
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import {
|
|
32
|
+
API_PREFIX,
|
|
33
|
+
fetchState,
|
|
34
|
+
type AbortControllerLike,
|
|
35
|
+
type AbortSignalLike,
|
|
36
|
+
type StateSnapshot,
|
|
37
|
+
type TimerHandle,
|
|
38
|
+
} from './api.ts'
|
|
39
|
+
|
|
40
|
+
/** SSE endpoint within the plugin route namespace (host: routes.ts). */
|
|
41
|
+
export const STREAM_PATH = `${API_PREFIX}/stream`
|
|
42
|
+
|
|
43
|
+
export type StreamMode = 'sse' | 'poll'
|
|
44
|
+
|
|
45
|
+
/** Unified connection status shown to the UI in both modes. */
|
|
46
|
+
export type StreamStatus = 'connecting' | 'open' | 'degraded'
|
|
47
|
+
|
|
48
|
+
export type SnapshotListener = (snapshot: StateSnapshot) => void
|
|
49
|
+
export type StatusListener = (status: StreamStatus) => void
|
|
50
|
+
|
|
51
|
+
/** Minimal shape of a named SSE message event. */
|
|
52
|
+
export interface EventSourceMessageLike {
|
|
53
|
+
data?: unknown
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Structural EventSource, so node tests can fake it and DOM satisfies it. */
|
|
57
|
+
export interface EventSourceLike {
|
|
58
|
+
readonly readyState: number
|
|
59
|
+
addEventListener(type: string, listener: (ev: EventSourceMessageLike) => void): void
|
|
60
|
+
close(): void
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type EventSourceFactory = (url: string) => EventSourceLike
|
|
64
|
+
|
|
65
|
+
/** Poll-mode snapshot source; receives an abort signal wired to stop(). */
|
|
66
|
+
export type PollFn = (opts: { signal?: AbortSignalLike }) => Promise<StateSnapshot>
|
|
67
|
+
|
|
68
|
+
/** EventSource.CLOSED (numeric literal so fakes need no DOM constants). */
|
|
69
|
+
const EVENTSOURCE_CLOSED = 2
|
|
70
|
+
|
|
71
|
+
const DEFAULT_POLL_ACTIVE_MS = 3_000
|
|
72
|
+
const DEFAULT_POLL_IDLE_MS = 15_000
|
|
73
|
+
const DEFAULT_ERROR_THRESHOLD = 3
|
|
74
|
+
const DEFAULT_BACKOFF_BASE_MS = 5_000
|
|
75
|
+
const DEFAULT_BACKOFF_CAP_MS = 30_000
|
|
76
|
+
|
|
77
|
+
export interface StateStreamOptions {
|
|
78
|
+
/** Stream endpoint for 'sse' mode; see {@link STREAM_PATH}. */
|
|
79
|
+
url: string
|
|
80
|
+
mode: StreamMode
|
|
81
|
+
/** SSE transport factory. Default: `new EventSource(url)`. */
|
|
82
|
+
eventSourceFactory?: EventSourceFactory
|
|
83
|
+
/** Poll-mode snapshot source. Default: api.fetchState. */
|
|
84
|
+
pollFn?: PollFn
|
|
85
|
+
/** Poll cadence while some session is `working`. Default 3000. */
|
|
86
|
+
pollActiveMs?: number
|
|
87
|
+
/** Poll cadence while everything is idle. Default 15000. */
|
|
88
|
+
pollIdleMs?: number
|
|
89
|
+
/** Visibility gate (inverse of document.hidden); absent = always visible. */
|
|
90
|
+
visible?: () => boolean
|
|
91
|
+
setTimeout?: (fn: () => void, ms: number) => TimerHandle
|
|
92
|
+
clearTimeout?: (handle: TimerHandle) => void
|
|
93
|
+
/** Abort factory for in-flight poll fetches. Default: real AbortController. */
|
|
94
|
+
createAbortController?: () => AbortControllerLike
|
|
95
|
+
/** Consecutive SSE errors tolerated before degrading. Default 3. */
|
|
96
|
+
errorThreshold?: number
|
|
97
|
+
/** First manual-rebuild backoff delay. Default 5000. */
|
|
98
|
+
backoffBaseMs?: number
|
|
99
|
+
/** Manual-rebuild backoff ceiling. Default 30000. */
|
|
100
|
+
backoffCapMs?: number
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const defaultSetTimeout = (fn: () => void, ms: number): TimerHandle =>
|
|
104
|
+
globalThis.setTimeout(fn, ms)
|
|
105
|
+
|
|
106
|
+
const defaultClearTimeout = (handle: TimerHandle): void => {
|
|
107
|
+
globalThis.clearTimeout(handle as ReturnType<typeof globalThis.setTimeout>)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const defaultCreateAbortController = (): AbortControllerLike => new AbortController()
|
|
111
|
+
|
|
112
|
+
const defaultEventSourceFactory: EventSourceFactory = (url) => {
|
|
113
|
+
const Ctor = (globalThis as { EventSource?: new (url: string) => unknown }).EventSource
|
|
114
|
+
if (Ctor === undefined) {
|
|
115
|
+
throw new Error('EventSource unavailable: inject eventSourceFactory or use poll mode')
|
|
116
|
+
}
|
|
117
|
+
return new Ctor(url) as EventSourceLike
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const defaultPollFn: PollFn = (opts) => fetchState({ signal: opts.signal })
|
|
121
|
+
|
|
122
|
+
export class StateStream {
|
|
123
|
+
private readonly url: string
|
|
124
|
+
private readonly streamMode: StreamMode
|
|
125
|
+
private readonly esFactory: EventSourceFactory
|
|
126
|
+
private readonly pollFn: PollFn
|
|
127
|
+
private readonly pollActiveMs: number
|
|
128
|
+
private readonly pollIdleMs: number
|
|
129
|
+
private readonly visibleFn: (() => boolean) | undefined
|
|
130
|
+
private readonly setT: (fn: () => void, ms: number) => TimerHandle
|
|
131
|
+
private readonly clearT: (handle: TimerHandle) => void
|
|
132
|
+
private readonly createController: () => AbortControllerLike
|
|
133
|
+
private readonly errorThreshold: number
|
|
134
|
+
private readonly backoffBaseMs: number
|
|
135
|
+
private readonly backoffCapMs: number
|
|
136
|
+
|
|
137
|
+
private currentStatus: StreamStatus = 'connecting'
|
|
138
|
+
private started = false
|
|
139
|
+
private stopped = false
|
|
140
|
+
private readonly snapshotListeners = new Set<SnapshotListener>()
|
|
141
|
+
private readonly statusListeners = new Set<StatusListener>()
|
|
142
|
+
|
|
143
|
+
// --- sse state ---
|
|
144
|
+
private es: EventSourceLike | null = null
|
|
145
|
+
private sseErrors = 0
|
|
146
|
+
private rebuildAttempts = 0
|
|
147
|
+
private rebuildTimer: TimerHandle | null = null
|
|
148
|
+
|
|
149
|
+
// --- poll state ---
|
|
150
|
+
private pollTimer: TimerHandle | null = null
|
|
151
|
+
private inFlight: AbortControllerLike | null = null
|
|
152
|
+
private lastSnapshot: StateSnapshot | null = null
|
|
153
|
+
|
|
154
|
+
constructor(opts: StateStreamOptions) {
|
|
155
|
+
this.url = opts.url
|
|
156
|
+
this.streamMode = opts.mode
|
|
157
|
+
this.esFactory = opts.eventSourceFactory ?? defaultEventSourceFactory
|
|
158
|
+
this.pollFn = opts.pollFn ?? defaultPollFn
|
|
159
|
+
this.pollActiveMs = opts.pollActiveMs ?? DEFAULT_POLL_ACTIVE_MS
|
|
160
|
+
this.pollIdleMs = opts.pollIdleMs ?? DEFAULT_POLL_IDLE_MS
|
|
161
|
+
this.visibleFn = opts.visible
|
|
162
|
+
this.setT = opts.setTimeout ?? defaultSetTimeout
|
|
163
|
+
this.clearT = opts.clearTimeout ?? defaultClearTimeout
|
|
164
|
+
this.createController = opts.createAbortController ?? defaultCreateAbortController
|
|
165
|
+
this.errorThreshold = opts.errorThreshold ?? DEFAULT_ERROR_THRESHOLD
|
|
166
|
+
this.backoffBaseMs = opts.backoffBaseMs ?? DEFAULT_BACKOFF_BASE_MS
|
|
167
|
+
this.backoffCapMs = opts.backoffCapMs ?? DEFAULT_BACKOFF_CAP_MS
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
get mode(): StreamMode {
|
|
171
|
+
return this.streamMode
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
get status(): StreamStatus {
|
|
175
|
+
return this.currentStatus
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Begin streaming. One-shot: calling again (or after stop) is a no-op. */
|
|
179
|
+
start(): void {
|
|
180
|
+
if (this.started || this.stopped) return
|
|
181
|
+
this.started = true
|
|
182
|
+
if (this.streamMode === 'sse') this.connectSse()
|
|
183
|
+
else this.pollTick()
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Terminal teardown: clears every timer, closes the EventSource, aborts
|
|
188
|
+
* the in-flight poll fetch, and drops all listeners. Idempotent; a
|
|
189
|
+
* stopped stream cannot be restarted (build a fresh instance instead).
|
|
190
|
+
*/
|
|
191
|
+
stop(): void {
|
|
192
|
+
if (this.stopped) return
|
|
193
|
+
this.stopped = true
|
|
194
|
+
if (this.rebuildTimer !== null) {
|
|
195
|
+
this.clearT(this.rebuildTimer)
|
|
196
|
+
this.rebuildTimer = null
|
|
197
|
+
}
|
|
198
|
+
if (this.pollTimer !== null) {
|
|
199
|
+
this.clearT(this.pollTimer)
|
|
200
|
+
this.pollTimer = null
|
|
201
|
+
}
|
|
202
|
+
const es = this.es
|
|
203
|
+
this.es = null
|
|
204
|
+
if (es !== null) es.close()
|
|
205
|
+
const inFlight = this.inFlight
|
|
206
|
+
this.inFlight = null
|
|
207
|
+
if (inFlight !== null) inFlight.abort()
|
|
208
|
+
this.snapshotListeners.clear()
|
|
209
|
+
this.statusListeners.clear()
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Subscribe to snapshots; returns the unsubscribe function. */
|
|
213
|
+
onSnapshot(cb: SnapshotListener): () => void {
|
|
214
|
+
if (this.stopped) return () => {}
|
|
215
|
+
this.snapshotListeners.add(cb)
|
|
216
|
+
return () => {
|
|
217
|
+
this.snapshotListeners.delete(cb)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Subscribe to status changes; fires synchronously with the current
|
|
223
|
+
* status on subscription, then on every transition. Returns the
|
|
224
|
+
* unsubscribe function.
|
|
225
|
+
*/
|
|
226
|
+
onStatus(cb: StatusListener): () => void {
|
|
227
|
+
if (this.stopped) return () => {}
|
|
228
|
+
this.statusListeners.add(cb)
|
|
229
|
+
cb(this.currentStatus)
|
|
230
|
+
return () => {
|
|
231
|
+
this.statusListeners.delete(cb)
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Immediate out-of-cadence poll (poll mode only). This is the
|
|
237
|
+
* visibility-resume hook: wire the document's `visibilitychange` event
|
|
238
|
+
* to call this when the page becomes visible again, so "resume → fetch
|
|
239
|
+
* immediately" holds without waiting for the next scheduled tick. Any
|
|
240
|
+
* pending tick is cancelled first, so cadence never doubles up.
|
|
241
|
+
*/
|
|
242
|
+
pollNow(): void {
|
|
243
|
+
if (!this.started || this.stopped || this.streamMode !== 'poll') return
|
|
244
|
+
if (this.pollTimer !== null) {
|
|
245
|
+
this.clearT(this.pollTimer)
|
|
246
|
+
this.pollTimer = null
|
|
247
|
+
}
|
|
248
|
+
this.pollTick()
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ------------------------------------------------------------------ sse
|
|
252
|
+
|
|
253
|
+
private connectSse(): void {
|
|
254
|
+
if (this.stopped) return
|
|
255
|
+
const es = this.esFactory(this.url)
|
|
256
|
+
this.es = es
|
|
257
|
+
es.addEventListener('open', () => {
|
|
258
|
+
if (this.stopped || this.es !== es) return
|
|
259
|
+
this.sseErrors = 0
|
|
260
|
+
this.rebuildAttempts = 0
|
|
261
|
+
this.setStatus('open')
|
|
262
|
+
})
|
|
263
|
+
es.addEventListener('state', (ev) => {
|
|
264
|
+
if (this.stopped || this.es !== es) return
|
|
265
|
+
if (typeof ev.data !== 'string') return
|
|
266
|
+
let parsed: unknown
|
|
267
|
+
try {
|
|
268
|
+
parsed = JSON.parse(ev.data)
|
|
269
|
+
} catch {
|
|
270
|
+
return
|
|
271
|
+
}
|
|
272
|
+
if (typeof parsed !== 'object' || parsed === null) return
|
|
273
|
+
this.emitSnapshot(parsed as StateSnapshot)
|
|
274
|
+
})
|
|
275
|
+
es.addEventListener('error', () => {
|
|
276
|
+
if (this.stopped || this.es !== es) return
|
|
277
|
+
this.sseErrors += 1
|
|
278
|
+
if (es.readyState === EVENTSOURCE_CLOSED || this.sseErrors > this.errorThreshold) {
|
|
279
|
+
this.scheduleRebuild()
|
|
280
|
+
} else {
|
|
281
|
+
// EventSource's native auto-reconnect is still in charge.
|
|
282
|
+
this.setStatus('connecting')
|
|
283
|
+
}
|
|
284
|
+
})
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Take over from native auto-reconnect: close the instance, surface
|
|
289
|
+
* `degraded`, and rebuild after a doubling delay capped at
|
|
290
|
+
* backoffCapMs. Never gives up — the ladder just stays at the cap.
|
|
291
|
+
*/
|
|
292
|
+
private scheduleRebuild(): void {
|
|
293
|
+
const es = this.es
|
|
294
|
+
this.es = null
|
|
295
|
+
if (es !== null) es.close()
|
|
296
|
+
this.setStatus('degraded')
|
|
297
|
+
const delay = Math.min(this.backoffBaseMs * 2 ** this.rebuildAttempts, this.backoffCapMs)
|
|
298
|
+
this.rebuildAttempts += 1
|
|
299
|
+
this.rebuildTimer = this.setT(() => {
|
|
300
|
+
this.rebuildTimer = null
|
|
301
|
+
this.connectSse()
|
|
302
|
+
}, delay)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ----------------------------------------------------------------- poll
|
|
306
|
+
|
|
307
|
+
private hasWorkingSession(): boolean {
|
|
308
|
+
const snap = this.lastSnapshot
|
|
309
|
+
if (snap === null) return false
|
|
310
|
+
return snap.board.sessions.some((s) => s.status === 'working')
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
private scheduleNextPoll(): void {
|
|
314
|
+
if (this.stopped) return
|
|
315
|
+
const delay = this.hasWorkingSession() ? this.pollActiveMs : this.pollIdleMs
|
|
316
|
+
this.pollTimer = this.setT(() => {
|
|
317
|
+
this.pollTimer = null
|
|
318
|
+
this.pollTick()
|
|
319
|
+
}, delay)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
private pollTick(): void {
|
|
323
|
+
if (this.stopped) return
|
|
324
|
+
if (this.visibleFn !== undefined && !this.visibleFn()) {
|
|
325
|
+
// Hidden: pause fetching but keep ticking as a cheap visibility
|
|
326
|
+
// check, so a tab left hidden resumes by itself at the next tick.
|
|
327
|
+
this.scheduleNextPoll()
|
|
328
|
+
return
|
|
329
|
+
}
|
|
330
|
+
if (this.inFlight !== null) {
|
|
331
|
+
// A slow previous poll is still running; never overlap fetches.
|
|
332
|
+
this.scheduleNextPoll()
|
|
333
|
+
return
|
|
334
|
+
}
|
|
335
|
+
const controller = this.createController()
|
|
336
|
+
this.inFlight = controller
|
|
337
|
+
this.pollFn({ signal: controller.signal }).then(
|
|
338
|
+
(snapshot) => {
|
|
339
|
+
if (this.stopped || this.inFlight !== controller) return
|
|
340
|
+
this.inFlight = null
|
|
341
|
+
this.lastSnapshot = snapshot
|
|
342
|
+
this.setStatus('open')
|
|
343
|
+
this.emitSnapshot(snapshot)
|
|
344
|
+
this.scheduleNextPoll()
|
|
345
|
+
},
|
|
346
|
+
() => {
|
|
347
|
+
if (this.stopped || this.inFlight !== controller) return
|
|
348
|
+
this.inFlight = null
|
|
349
|
+
this.setStatus('degraded')
|
|
350
|
+
this.scheduleNextPoll()
|
|
351
|
+
},
|
|
352
|
+
)
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// ------------------------------------------------------------- plumbing
|
|
356
|
+
|
|
357
|
+
private setStatus(status: StreamStatus): void {
|
|
358
|
+
if (this.currentStatus === status) return
|
|
359
|
+
this.currentStatus = status
|
|
360
|
+
for (const cb of [...this.statusListeners]) cb(status)
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
private emitSnapshot(snapshot: StateSnapshot): void {
|
|
364
|
+
for (const cb of [...this.snapshotListeners]) cb(snapshot)
|
|
365
|
+
}
|
|
366
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Footer status widget (design §5.1 view 4, `sidebar.footer.action` slot).
|
|
3
|
+
*
|
|
4
|
+
* Presentation-only: connection state and working count arrive as props
|
|
5
|
+
* (derive them with `deriveWidgetConnection` / `countWorking` from
|
|
6
|
+
* `board/logic.ts`); the click callback is wired to the board tab by the
|
|
7
|
+
* integration layer.
|
|
8
|
+
*
|
|
9
|
+
* Quiet by design: at zero working sessions only the connection dot shows,
|
|
10
|
+
* no counter, no animation — the DOM stays stable so the footer never
|
|
11
|
+
* flickers or shifts (task spec: 零数据时安静).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { CSSProperties, ReactElement } from 'react'
|
|
15
|
+
import { widgetTitle, type WidgetConnection } from './board/logic.ts'
|
|
16
|
+
|
|
17
|
+
export interface SidecarWidgetProps {
|
|
18
|
+
connection: WidgetConnection
|
|
19
|
+
workingCount: number
|
|
20
|
+
/** Opens the board tab; wired by the integration layer. */
|
|
21
|
+
onOpen?: () => void
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const DOT_COLORS: Record<WidgetConnection, string> = {
|
|
25
|
+
ok: 'var(--dsw-alias-state-success-primary, #1a7f37)',
|
|
26
|
+
degraded: 'var(--dsw-alias-state-warn-primary, #9a6700)',
|
|
27
|
+
off: 'var(--dsw-alias-label-dimmed, #8c959f)',
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const rootStyle: CSSProperties = {
|
|
31
|
+
display: 'inline-flex',
|
|
32
|
+
alignItems: 'center',
|
|
33
|
+
gap: 4,
|
|
34
|
+
padding: '2px 6px',
|
|
35
|
+
border: 'none',
|
|
36
|
+
borderRadius: 6,
|
|
37
|
+
background: 'transparent',
|
|
38
|
+
cursor: 'pointer',
|
|
39
|
+
font: 'inherit',
|
|
40
|
+
color: 'var(--dsw-alias-label-secondary, #57606a)',
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const countStyle: CSSProperties = {
|
|
44
|
+
fontSize: 11,
|
|
45
|
+
fontVariantNumeric: 'tabular-nums',
|
|
46
|
+
lineHeight: '14px',
|
|
47
|
+
whiteSpace: 'nowrap',
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Connection dot + working-session counter (e.g. `▸2`) for the footer. */
|
|
51
|
+
export function SidecarWidget(props: SidecarWidgetProps): ReactElement {
|
|
52
|
+
const title = widgetTitle(props.connection, props.workingCount)
|
|
53
|
+
return (
|
|
54
|
+
<button
|
|
55
|
+
type="button"
|
|
56
|
+
style={rootStyle}
|
|
57
|
+
title={title}
|
|
58
|
+
aria-label={title}
|
|
59
|
+
onClick={props.onOpen}
|
|
60
|
+
data-testid="agent-sidecar-widget"
|
|
61
|
+
data-connection={props.connection}
|
|
62
|
+
>
|
|
63
|
+
<span
|
|
64
|
+
aria-hidden
|
|
65
|
+
style={{
|
|
66
|
+
width: 8,
|
|
67
|
+
height: 8,
|
|
68
|
+
borderRadius: '50%',
|
|
69
|
+
flex: 'none',
|
|
70
|
+
background: DOT_COLORS[props.connection],
|
|
71
|
+
}}
|
|
72
|
+
/>
|
|
73
|
+
{props.workingCount > 0 && (
|
|
74
|
+
<span style={countStyle} data-testid="agent-sidecar-widget-count">
|
|
75
|
+
{`▸${props.workingCount}`}
|
|
76
|
+
</span>
|
|
77
|
+
)}
|
|
78
|
+
</button>
|
|
79
|
+
)
|
|
80
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Composition config for the dsh-agent-sidecar host half (design §6, scoped
|
|
3
|
+
* to the task-approved field set). schemastery-validated by the cordis
|
|
4
|
+
* Loader; every field carries a default so a bare patch row (no `config:`
|
|
5
|
+
* block) mounts with zero configuration, and `.description()` strings feed
|
|
6
|
+
* the dsh settings pane renderer.
|
|
7
|
+
*
|
|
8
|
+
* Grouping mirrors the runtime module it feeds:
|
|
9
|
+
* - `daemon` → DaemonSupervisor (src/supervisor.ts)
|
|
10
|
+
* - `sidecar` → CLI/daemon invocation (command + runtime dir redirect)
|
|
11
|
+
* - `stream` → Reconciler cadences (src/bridge.ts)
|
|
12
|
+
* - `inject` → the guard's write gate (src/guard.ts); M2 consumes the rest
|
|
13
|
+
* - `analysis` / `ui` / `skill` → M3/M4 surfaces, contractual today so the
|
|
14
|
+
* config face does not churn per milestone.
|
|
15
|
+
*
|
|
16
|
+
* @module
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import z from '@deepseek-ai/schemastery'
|
|
20
|
+
import type { SupervisorPolicy } from './supervisor.ts'
|
|
21
|
+
|
|
22
|
+
/** Daemon lifecycle governance (design §4.a). */
|
|
23
|
+
export interface DaemonConfig {
|
|
24
|
+
/** adopt-or-host: probe→adopt→else spawn; adopt-only: never spawn; off: no lifecycle management (read-only reconcile still runs). */
|
|
25
|
+
policy: SupervisorPolicy
|
|
26
|
+
/** Consecutive hosting failures before the supervisor trips FAILED. */
|
|
27
|
+
backoffLimit: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** How to reach/launch the sidecar itself. */
|
|
31
|
+
export interface SidecarInvocationConfig {
|
|
32
|
+
/** argv prefix of the sidecar executable (PATH name, absolute path, or e.g. python3+zipapp as multiple entries). */
|
|
33
|
+
command: string[]
|
|
34
|
+
/** Empty = default `~/.agent_sidecar` (honoring AGENT_SIDECAR_RUNTIME_DIR); non-empty redirects via env for spawned daemons. */
|
|
35
|
+
runtimeDir: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Reconciler snapshot cadences (design §4.b / ADR-2). */
|
|
39
|
+
export interface StreamConfig {
|
|
40
|
+
/** `status` snapshot cadence while any session is working (ms). */
|
|
41
|
+
reconcileActiveMs: number
|
|
42
|
+
/** `status` snapshot cadence otherwise (ms). */
|
|
43
|
+
reconcileIdleMs: number
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Write-path master switch and defaults (M2 consumes defaultMode). */
|
|
47
|
+
export interface InjectConfig {
|
|
48
|
+
/** Master gate: false hides all inject affordances and 403s write actions server-side. */
|
|
49
|
+
enabled: boolean
|
|
50
|
+
/** Default injection mode offered by the inject panel. */
|
|
51
|
+
defaultMode: 'queue' | 'steer'
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** AI bypass-analysis switch and model routing (M3). */
|
|
55
|
+
export interface AnalysisConfig {
|
|
56
|
+
enabled: boolean
|
|
57
|
+
/**
|
|
58
|
+
* Explicit provider route for the dedicated analysis agents. Empty (the
|
|
59
|
+
* default) reuses the host's default model selection (`agentDefaultModel`
|
|
60
|
+
* service, the same source dsh's own entry points read). Takes effect
|
|
61
|
+
* only together with a non-empty `model`.
|
|
62
|
+
*/
|
|
63
|
+
provider: string
|
|
64
|
+
/** Explicit model id for the analysis agents; see {@link provider}. */
|
|
65
|
+
model: string
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Board rendering knobs (client half). */
|
|
69
|
+
export interface UiConfig {
|
|
70
|
+
/** Session recency window shown on the board (hours). */
|
|
71
|
+
timeWindowHours: number
|
|
72
|
+
/** Whether dead sessions are listed. */
|
|
73
|
+
showDead: boolean
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Skill provider switch (design §6/§7 path two; wired in M4/T6.2). */
|
|
77
|
+
export interface SkillConfig {
|
|
78
|
+
/** Register the embedded agent-sidecar skill on ctx.skills (read at apply; restart semantics). */
|
|
79
|
+
provide: boolean
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Validated composition config (all defaults filled by the schema). */
|
|
83
|
+
export interface Config {
|
|
84
|
+
daemon: DaemonConfig
|
|
85
|
+
sidecar: SidecarInvocationConfig
|
|
86
|
+
stream: StreamConfig
|
|
87
|
+
inject: InjectConfig
|
|
88
|
+
analysis: AnalysisConfig
|
|
89
|
+
ui: UiConfig
|
|
90
|
+
skill: SkillConfig
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export const Config: z<Config> = z.object({
|
|
94
|
+
daemon: z
|
|
95
|
+
.object({
|
|
96
|
+
policy: z
|
|
97
|
+
.union([z.const('adopt-or-host'), z.const('adopt-only'), z.const('off')])
|
|
98
|
+
.default('adopt-or-host')
|
|
99
|
+
.description(
|
|
100
|
+
'daemon 托管策略:adopt-or-host=探测并领养既有 daemon,否则自行拉起;adopt-only=只领养绝不拉起;off=不管理 daemon 生命周期(仍只读对账既有 daemon 的数据)',
|
|
101
|
+
),
|
|
102
|
+
backoffLimit: z
|
|
103
|
+
.natural()
|
|
104
|
+
.min(1)
|
|
105
|
+
.default(5)
|
|
106
|
+
.description('托管失败熔断阈值:连续失败达到该次数后停止重启并进入 FAILED'),
|
|
107
|
+
})
|
|
108
|
+
.description('daemon 生命周期治理'),
|
|
109
|
+
sidecar: z
|
|
110
|
+
.object({
|
|
111
|
+
command: z
|
|
112
|
+
.array(String)
|
|
113
|
+
.default(['agent-sidecar'])
|
|
114
|
+
.description(
|
|
115
|
+
'sidecar 可执行命令(argv 前缀):PATH 名、绝对路径,或多段命令(如 ["python3", "/path/to/agent-sidecar.pyz"]);插件绝不代装',
|
|
116
|
+
),
|
|
117
|
+
runtimeDir: z
|
|
118
|
+
.string()
|
|
119
|
+
.default('')
|
|
120
|
+
.description(
|
|
121
|
+
'运行时目录:留空用默认 ~/.agent_sidecar(尊重 AGENT_SIDECAR_RUNTIME_DIR 环境变量);非空时经环境变量传给受托管的 daemon',
|
|
122
|
+
),
|
|
123
|
+
})
|
|
124
|
+
.description('sidecar 调用方式'),
|
|
125
|
+
stream: z
|
|
126
|
+
.object({
|
|
127
|
+
reconcileActiveMs: z
|
|
128
|
+
.natural()
|
|
129
|
+
.min(100)
|
|
130
|
+
.default(2000)
|
|
131
|
+
.description('对账快照周期(有会话工作中,毫秒)'),
|
|
132
|
+
reconcileIdleMs: z
|
|
133
|
+
.natural()
|
|
134
|
+
.min(100)
|
|
135
|
+
.default(10000)
|
|
136
|
+
.description('对账快照周期(空闲,毫秒)'),
|
|
137
|
+
})
|
|
138
|
+
.description('数据流对账节奏'),
|
|
139
|
+
inject: z
|
|
140
|
+
.object({
|
|
141
|
+
enabled: z
|
|
142
|
+
.boolean()
|
|
143
|
+
.default(false)
|
|
144
|
+
.description(
|
|
145
|
+
'注入总开关:关闭时看板隐藏全部注入入口,写接口在服务端同步拒绝(默认关闭;多用户主机不建议开启)',
|
|
146
|
+
),
|
|
147
|
+
defaultMode: z
|
|
148
|
+
.union([z.const('queue'), z.const('steer')])
|
|
149
|
+
.default('queue')
|
|
150
|
+
.description('注入面板默认模式:queue=排队下一轮,steer=中途注入'),
|
|
151
|
+
})
|
|
152
|
+
.description('消息注入'),
|
|
153
|
+
analysis: z
|
|
154
|
+
.object({
|
|
155
|
+
enabled: z
|
|
156
|
+
.boolean()
|
|
157
|
+
.default(false)
|
|
158
|
+
.description('AI 旁路分析开关(M3;消耗模型 token,默认关闭)'),
|
|
159
|
+
provider: z
|
|
160
|
+
.string()
|
|
161
|
+
.default('')
|
|
162
|
+
.description(
|
|
163
|
+
'分析代理的 provider 路由:留空(默认)复用宿主默认模型(agentDefaultModel 服务);与 model 同时非空才生效',
|
|
164
|
+
),
|
|
165
|
+
model: z
|
|
166
|
+
.string()
|
|
167
|
+
.default('')
|
|
168
|
+
.description(
|
|
169
|
+
'分析代理的模型 id:留空(默认)复用宿主默认模型;与 provider 同时非空才生效',
|
|
170
|
+
),
|
|
171
|
+
})
|
|
172
|
+
.description('旁路分析'),
|
|
173
|
+
ui: z
|
|
174
|
+
.object({
|
|
175
|
+
timeWindowHours: z
|
|
176
|
+
.natural()
|
|
177
|
+
.min(1)
|
|
178
|
+
.default(24)
|
|
179
|
+
.description('看板会话时间窗(小时)'),
|
|
180
|
+
showDead: z.boolean().default(false).description('是否显示 dead 会话'),
|
|
181
|
+
})
|
|
182
|
+
.description('看板界面'),
|
|
183
|
+
skill: z
|
|
184
|
+
.object({
|
|
185
|
+
provide: z
|
|
186
|
+
.boolean()
|
|
187
|
+
.default(true)
|
|
188
|
+
.description(
|
|
189
|
+
'是否经 registerProvider 内嵌提供 agent-sidecar skill(设计 §6 默认开;文件系统已装的同名 skill 自动优先;改动需重载插件生效)',
|
|
190
|
+
),
|
|
191
|
+
})
|
|
192
|
+
.description('skill 模式'),
|
|
193
|
+
})
|