@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,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
+ })