@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
package/src/index.ts ADDED
@@ -0,0 +1,950 @@
1
+ /**
2
+ * Agent Sidecar — dsh host-half plugin entry (M1 + M2 + M3 assembly).
3
+ *
4
+ * Wires the pure modules onto the cordis context:
5
+ * SessionStore → SidecarSocketClient → FusionQuery (holder) → Reconciler
6
+ * → DaemonSupervisor → InjectGateway (dsh + send-cli executors)
7
+ * → AnalysisEngine (lazy agents.create + fusion input adapter)
8
+ * → createRoutes → ctx.webServer prefix route.
9
+ *
10
+ * M3 fusion wiring in brief: FusionQuery lives behind a holder facade so
11
+ * the dsh event feed can bind lazily (`ctx.inject(['sessions'])`, swap-in/
12
+ * swap-out); sessionQuery resolves per call via reflect `get`; the daemon
13
+ * `replay` op arrives through a paging adapter over `client.replay`; and
14
+ * the reconciler's store face tees subscribe events into fusion's ring.
15
+ *
16
+ * Named exports only: postmortem 0001 documents that a default-exported
17
+ * plugin object silently drops `inject`, so the loader must see the named
18
+ * `name`/`inject`/`Config`/`apply` faces directly on the module namespace.
19
+ *
20
+ * `inject` declares only the two services every milestone needs (webServer,
21
+ * subprocess). `agents` — the M2 dsh in-process injection path — is
22
+ * consumed through a LAZY `ctx.inject(['agents'], …)` instead: cordis
23
+ * `inject` knows no optional tier (`Inject = (keyof M)[] | map`, all
24
+ * required), so a top-level declaration would pend the whole fiber in any
25
+ * composition without dsh-agent and take the M1 read surface down with it.
26
+ * dsh-base does bundle `@deepseek-ai/dsh-agent`, so in standard
27
+ * compositions the lazy callback fires at boot anyway; in agent-less
28
+ * compositions the plugin still loads and the injection surface degrades
29
+ * to the send-cli path only (a dsh-target execute fails with an honest
30
+ * "agents service unavailable" detail) while the M3 analysis actions
31
+ * answer 501 `analysis_unavailable` (same binding gates both).
32
+ *
33
+ * Service contracts consumed here were verified against the installed dsh
34
+ * 0.1.1-rc.2 type declarations, not docs:
35
+ * - `ctx.webServer.register({kind:'prefix', path, handler})` → disposer;
36
+ * handler is plain node:http and owns the response lifecycle
37
+ * (@deepseek-ai/dsh-host-webserver lib/types/index.d.ts).
38
+ * - `ctx.subprocess.spawn(spec)` is fully explicit (argv/cwd/stdio/graceMs/
39
+ * env, argv never shell-interpreted); the handle exposes `done`,
40
+ * `stdin` (iff spawned with `stdin: 'pipe'`), tree-scoped `terminate()`
41
+ * (SIGTERM → graceMs → SIGKILL) and `waitForExit()`
42
+ * (@deepseek-ai/dsh-subprocess lib/types/types.d.ts).
43
+ * - `ctx.agents` is dsh-agent's AgentRegistry (`get(id)` live lookup,
44
+ * `resume({resumeSessionId})` → AgentHandle); the structural
45
+ * {@link AgentsServiceFace} in dsh-inject.ts is satisfied directly.
46
+ * The faces below are structural on purpose: the plugin's type surface
47
+ * stays on the two devDependency SDKs (cordis, schemastery) while the
48
+ * service packages resolve at runtime from the dsh profile tree.
49
+ *
50
+ * @module @shendeguize/dsh-agent-sidecar
51
+ */
52
+
53
+ import type { IncomingMessage, ServerResponse } from 'node:http'
54
+ import { homedir } from 'node:os'
55
+ import { isAbsolute, join, resolve } from 'node:path'
56
+ import { createInterface } from 'node:readline'
57
+ import type { Readable, Writable } from 'node:stream'
58
+ import type { Context } from '@deepseek-ai/cordis'
59
+
60
+ import {
61
+ AnalysisEngine,
62
+ type AnalysisAgentFace,
63
+ type AnalysisInput,
64
+ type AnalysisSession,
65
+ } from './analysis.ts'
66
+ import { Config } from './config.ts'
67
+ import { Reconciler, SidecarSocketClient } from './bridge.ts'
68
+ import { createDshInjectExecutor, type AgentsServiceFace } from './dsh-inject.ts'
69
+ import {
70
+ FusionQuery,
71
+ type DshEventFace,
72
+ type SessionQueryFace,
73
+ type SidecarEventFace,
74
+ type SidecarReplayFace,
75
+ type UnifiedSession,
76
+ } from './fusion.ts'
77
+ import type { GuardOptions } from './guard.ts'
78
+ import {
79
+ InjectGateway,
80
+ type InjectTarget,
81
+ type TargetStatus,
82
+ } from './inject-gateway.ts'
83
+ import {
84
+ API_PREFIX,
85
+ createRoutes,
86
+ type AnalysisTargetRequest,
87
+ type FusionApi,
88
+ } from './routes.ts'
89
+ import { createSendCliExecutor, type SpawnLike } from './send-cli.ts'
90
+ import { SessionStore } from './session-store.ts'
91
+ import {
92
+ registerSidecarSkillProvider,
93
+ type SkillsServiceFace,
94
+ } from './skills-provider.ts'
95
+ import { DaemonSupervisor, type DaemonProcess, type LogLevel } from './supervisor.ts'
96
+
97
+ export { Config } from './config.ts'
98
+
99
+ export const name = 'agent-sidecar'
100
+
101
+ /** Required services; see the module doc for why `agents` is lazy instead. */
102
+ export const inject = ['webServer', 'subprocess']
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // Structural faces of the consumed dsh services (see module doc for sources).
106
+ // ---------------------------------------------------------------------------
107
+
108
+ /** `ctx.webServer` face (route registration only). */
109
+ export interface WebServerService {
110
+ register(route: {
111
+ kind: 'exact' | 'prefix'
112
+ path: string
113
+ handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>
114
+ }): () => void
115
+ }
116
+
117
+ /** Bounded in-memory collection for one child output stream. */
118
+ export interface SubprocessCollectSpec {
119
+ maxBytes: number
120
+ spill?: { maxBytes: number }
121
+ }
122
+
123
+ /** Fully-specified spawn request (`ctx.subprocess` applies no defaults). */
124
+ export interface SubprocessSpawnSpec {
125
+ argv: readonly string[]
126
+ cwd: string
127
+ stdio: {
128
+ stdin: 'ignore' | 'pipe' | { readonly data: string }
129
+ stdout: 'pipe' | 'inherit' | SubprocessCollectSpec
130
+ stderr: 'pipe' | 'inherit' | SubprocessCollectSpec
131
+ }
132
+ graceMs: number
133
+ signal?: AbortSignal
134
+ env?: NodeJS.ProcessEnv
135
+ }
136
+
137
+ /** Exit facts of one closed process (Node `close`-event vocabulary). */
138
+ export interface SubprocessOutcome {
139
+ exitCode: number | null
140
+ signal: NodeJS.Signals | null
141
+ }
142
+
143
+ /** Offset-based, non-consuming reader over one collect-mode stream. */
144
+ export interface SubprocessOutputReader {
145
+ readFrom(fromByte: number): { text: string; nextOffset: number; lossy: boolean }
146
+ }
147
+
148
+ /** Live child-process handle rooted in its own process tree. */
149
+ export interface SubprocessHandle {
150
+ readonly pid: number
151
+ /** Present iff spawned with `stdin: 'pipe'` (dsh-subprocess types.d.ts:158). */
152
+ readonly stdin: Writable | undefined
153
+ readonly stdout: Readable | undefined
154
+ readonly stderr: Readable | undefined
155
+ readonly collected: {
156
+ readonly stdout?: SubprocessOutputReader
157
+ readonly stderr?: SubprocessOutputReader
158
+ }
159
+ readonly done: Promise<SubprocessOutcome>
160
+ terminate(): void
161
+ waitForExit(signal?: AbortSignal): Promise<boolean>
162
+ }
163
+
164
+ /** `ctx.subprocess` face (managed pipe-process primitive only). */
165
+ export interface SubprocessService {
166
+ spawn(spec: SubprocessSpawnSpec): SubprocessHandle
167
+ }
168
+
169
+ /** Owner scope returned by `ctx.settings.register` (read/observe subset). */
170
+ export interface SettingsScopeFace<T> {
171
+ get(): T
172
+ watch(callback: (next: T, prev: T) => void): () => void
173
+ }
174
+
175
+ /**
176
+ * `ctx.settings` face (namespace registration only). Source:
177
+ * dsh-settings SettingsProvider.register — `register(ns, schema, {base,
178
+ * applies})` → owner scope; the namespace brand is compile-time only, so a
179
+ * plain string is structurally sound. Registration rides the CALLER's
180
+ * fiber (service proxy binds this.ctx), so disposal is automatic.
181
+ */
182
+ export interface SettingsServiceFace {
183
+ register<T>(
184
+ ns: string,
185
+ schema: unknown,
186
+ options?: { base?: Partial<T>; applies?: 'live' | 'restart' },
187
+ ): SettingsScopeFace<T>
188
+ }
189
+
190
+ /**
191
+ * The lazily-bound `ctx.agents` registry surface: M2 injection consumes
192
+ * `get`/`resume` (dsh-inject's {@link AgentsServiceFace}), M3 analysis
193
+ * consumes `create` (analysis.ts's {@link AnalysisAgentFace}). One lazy
194
+ * binding serves both paths — and gates both degradations.
195
+ */
196
+ export type AgentsRegistryFace = AgentsServiceFace & Pick<AnalysisAgentFace, 'create'>
197
+
198
+ /**
199
+ * `ctx.agentDefaultModel` face (dsh-agent-default-model index.d.ts:40-56):
200
+ * the host's default model selection — the SAME source dsh's own entry
201
+ * points read when creating agents (dsh-headless `run()` passes
202
+ * `agentOptions: {provider, model}` from `currentSelection()`;
203
+ * dsh-host-apiproxy exposes it as `defaultModelSelection`). Resolved per
204
+ * call via reflect `get` (never a hard inject): the service is core in
205
+ * dsh-base compositions but the plugin must degrade honestly without it.
206
+ */
207
+ export interface AgentDefaultModelFace {
208
+ currentSelection(): { provider: string; model: string }
209
+ }
210
+
211
+ /** The plugin context with the two hard-injected services visible. */
212
+ export type HostContext = Context & {
213
+ webServer: WebServerService
214
+ subprocess: SubprocessService
215
+ }
216
+
217
+ // ---------------------------------------------------------------------------
218
+ // Sidecar invocation facts (mirroring sidecar/daemon.py and launchd.py).
219
+ // ---------------------------------------------------------------------------
220
+
221
+ /** `SOCKET_NAME` in sidecar/daemon.py. */
222
+ const SOCKET_NAME = 'daemon.sock'
223
+ /** `RUNTIME_ENV` / `LEGACY_RUNTIME_ENV` in sidecar/daemon.py. */
224
+ const RUNTIME_ENV = 'AGENT_SIDECAR_RUNTIME_DIR'
225
+ const LEGACY_RUNTIME_ENV = 'AGENT_SIDECAR_HOME'
226
+
227
+ /** SIGTERM → grace → SIGKILL window for a hosted daemon (design §4.a: 5s). */
228
+ const DAEMON_GRACE_MS = 5000
229
+ /** Whole-run bound for one `service status` detection probe. */
230
+ const DETECT_TIMEOUT_MS = 10_000
231
+ /** SIGTERM → grace → SIGKILL window when the send-cli hard timeout kills. */
232
+ const SEND_CLI_GRACE_MS = 2000
233
+ /** Output cap for the detection probe (one sanitized message line). */
234
+ const DETECT_OUTPUT_BYTES = 4096
235
+ /** Per-line clamp when forwarding daemon output into ctx.logger (S8). */
236
+ const LOG_LINE_LIMIT = 400
237
+ /** Per-page `replay` limit forwarded to the daemon (its own cap is 1024). */
238
+ const REPLAY_PAGE_LIMIT = 512
239
+ /** Page cap per fusion replay pull: bounds one timeline fan-out to ≤2048 events. */
240
+ const REPLAY_MAX_PAGES = 4
241
+
242
+ // Bounds of the fusion→AnalysisInput adapter (§7-B: the engine re-bounds
243
+ // the whole text to maxInputChars anyway; these keep the assembly cheap
244
+ // and the head of the text — which survives engine truncation — useful).
245
+ /** Timeline entries pulled into one session-analysis summary. */
246
+ const ANALYSIS_TIMELINE_LIMIT = 120
247
+ /** Sessions listed per project-analysis overview. */
248
+ const ANALYSIS_MAX_SESSIONS = 30
249
+ /** Project groups listed in a cross-agent analysis overview. */
250
+ const ANALYSIS_MAX_GROUPS = 12
251
+ /** Sessions listed per group in a cross-agent analysis overview. */
252
+ const ANALYSIS_CROSS_SESSIONS = 5
253
+ /** Clamp on one line of untrusted text (titles, event text). */
254
+ const ANALYSIS_LINE_CLAMP = 200
255
+ /** Clamp on the user question (placed at the head, so it survives truncation). */
256
+ const ANALYSIS_QUESTION_CLAMP = 2000
257
+
258
+ /**
259
+ * `service status` messages that mean "a LaunchAgent owns daemon liveness"
260
+ * (sidecar/launchd.py `_status`): exit 0 is `service is running (pid N)`;
261
+ * exit 1 covers `service is loaded but daemon is not running` and
262
+ * `service is degraded; ...` (both installed) as well as
263
+ * `service is unloaded...` (not installed). There is no `--json` face —
264
+ * the single sanitized message line IS the contract.
265
+ */
266
+ const SERVICE_PRESENT = /^service is (?:running|loaded|degraded)/m
267
+
268
+ /**
269
+ * Resolve the effective runtime directory the way sidecar/daemon.py
270
+ * `default_runtime_dir()` does: explicit config wins, then the
271
+ * AGENT_SIDECAR_RUNTIME_DIR / legacy AGENT_SIDECAR_HOME environment of the
272
+ * dsh host process, then `~/.agent_sidecar`.
273
+ */
274
+ function resolveRuntimeDir(configured: string, env: NodeJS.ProcessEnv): string {
275
+ const raw =
276
+ configured.trim() !== ''
277
+ ? configured.trim()
278
+ : (env[RUNTIME_ENV] ?? env[LEGACY_RUNTIME_ENV] ?? '').trim()
279
+ if (raw === '') return join(homedir(), '.agent_sidecar')
280
+ const expanded =
281
+ raw === '~' ? homedir() : raw.startsWith('~/') ? join(homedir(), raw.slice(2)) : raw
282
+ return isAbsolute(expanded) ? expanded : resolve(expanded)
283
+ }
284
+
285
+ // ---------------------------------------------------------------------------
286
+ // Fusion → AnalysisInput assembly helpers (pure; bounded per the constants).
287
+ // ---------------------------------------------------------------------------
288
+
289
+ /** Flatten and clamp one line of untrusted text for an analysis summary. */
290
+ function clampAnalysisText(text: string, max = ANALYSIS_LINE_CLAMP): string {
291
+ const flat = text.replace(/\s+/g, ' ').trim()
292
+ return flat.length <= max ? flat : `${flat.slice(0, max)}…`
293
+ }
294
+
295
+ /** Same trailing-slash normalization fusion uses for project group keys. */
296
+ function normalizeAnalysisProject(project: string): string {
297
+ if (project.length > 1 && project.endsWith('/')) {
298
+ const stripped = project.replace(/\/+$/, '')
299
+ return stripped === '' ? '/' : stripped
300
+ }
301
+ return project
302
+ }
303
+
304
+ /** One unified-session line in a project / cross-agent overview. */
305
+ function describeUnifiedSession(session: UnifiedSession): string {
306
+ const title = session.title !== '' ? clampAnalysisText(session.title) : '(untitled)'
307
+ const live = session.live ? '|live' : ''
308
+ const updated = new Date(session.lastActivityAt).toISOString()
309
+ return `- [${session.agent}|${session.status}${live}] ${title} (updated ${updated})`
310
+ }
311
+
312
+ /**
313
+ * Assemble the M1 host half.
314
+ *
315
+ * Teardown is order-sensitive, so the whole assembly lives in ONE
316
+ * `ctx.effect` disposer (design §4.a: "顺序敏感拆除放同一 disposer"):
317
+ * supervisor first (terminates a self-hosted daemon, never an adopted one),
318
+ * then the reconciler (closes the subscribe stream and timers), then
319
+ * `routes.dispose()` (ends SSE clients, unsubscribes), and the webServer
320
+ * route disposer last.
321
+ *
322
+ * @param ctx - plugin context handed by the cordis loader.
323
+ * @param config - schema-validated composition config (defaults filled).
324
+ */
325
+ export function apply(ctx: HostContext, config: Config): void {
326
+ const runtimeDir = resolveRuntimeDir(config.sidecar.runtimeDir, process.env)
327
+ const socketPath = join(runtimeDir, SOCKET_NAME)
328
+ const command = config.sidecar.command
329
+ /** Explicit redirect only when configured; the ambient env already flows. */
330
+ const childEnv: NodeJS.ProcessEnv | undefined =
331
+ config.sidecar.runtimeDir.trim() !== '' ? { [RUNTIME_ENV]: runtimeDir } : undefined
332
+
333
+ const log = (level: LogLevel, msg: string, meta?: object): void => {
334
+ ctx.logger[level](
335
+ meta === undefined ? `agent-sidecar: ${msg}` : `agent-sidecar: ${msg} ${JSON.stringify(meta)}`,
336
+ )
337
+ }
338
+
339
+ /** Clamped per-line forwarding of daemon output (design §4.c, S8-safe). */
340
+ const forwardLines = (stream: Readable | undefined, level: 'debug' | 'warn'): void => {
341
+ if (stream === undefined) return
342
+ stream.on('error', () => {})
343
+ const lines = createInterface({ input: stream })
344
+ lines.on('line', (line) => {
345
+ const text = line.length > LOG_LINE_LIMIT ? `${line.slice(0, LOG_LINE_LIMIT)}…` : line
346
+ if (text.trim() !== '') ctx.logger[level](`agent-sidecar daemon: ${text}`)
347
+ })
348
+ }
349
+
350
+ /** Spawn `<command> daemon run` as a supervised foreground child. */
351
+ const spawnDaemon = (): DaemonProcess => {
352
+ const handle = ctx.subprocess.spawn({
353
+ argv: [...command, 'daemon', 'run'],
354
+ cwd: homedir(),
355
+ stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' },
356
+ graceMs: DAEMON_GRACE_MS,
357
+ env: childEnv,
358
+ })
359
+ forwardLines(handle.stdout, 'debug')
360
+ forwardLines(handle.stderr, 'warn')
361
+ return {
362
+ exited: handle.done.then((outcome) => outcome.exitCode),
363
+ // Gentle by construction: subprocess `terminate()` is the tree-scoped
364
+ // SIGTERM → graceMs → SIGKILL escalation; waitForExit observes the
365
+ // whole tree so teardown returns on real quiescence.
366
+ terminate: async () => {
367
+ handle.terminate()
368
+ await handle.waitForExit()
369
+ },
370
+ }
371
+ }
372
+
373
+ /**
374
+ * Read-only LaunchAgent detection: darwin-only, one bounded
375
+ * `service status` run, parsed per {@link SERVICE_PRESENT}. Any failure
376
+ * (non-zero control exit, timeout, unspawnable CLI) reads as "absent" —
377
+ * the supervisor already treats detection errors that way.
378
+ */
379
+ const detectLaunchAgent = async (): Promise<boolean> => {
380
+ if (process.platform !== 'darwin') return false
381
+ const handle = ctx.subprocess.spawn({
382
+ argv: [...command, 'service', 'status'],
383
+ cwd: homedir(),
384
+ stdio: {
385
+ stdin: 'ignore',
386
+ stdout: { maxBytes: DETECT_OUTPUT_BYTES },
387
+ stderr: { maxBytes: DETECT_OUTPUT_BYTES },
388
+ },
389
+ graceMs: 2000,
390
+ signal: AbortSignal.timeout(DETECT_TIMEOUT_MS),
391
+ env: childEnv,
392
+ })
393
+ const outcome = await handle.done
394
+ if (outcome.exitCode === 0) return true
395
+ if (outcome.exitCode !== 1) return false
396
+ const text = handle.collected.stdout?.readFrom(0).text ?? ''
397
+ return SERVICE_PRESENT.test(text)
398
+ }
399
+
400
+ // ------------------------------------------------------------- assembly
401
+
402
+ const store = new SessionStore()
403
+ const client = new SidecarSocketClient({ socketPath })
404
+
405
+ // ------------------------------------------------------ M3 fusion assembly
406
+
407
+ // SidecarReplayFace → bridge.replay: one fusion pull pages the daemon op
408
+ // until the history is exhausted, bounded to REPLAY_MAX_PAGES so a huge
409
+ // transcript can never wedge one HTTP request. Coded daemon errors
410
+ // (unknown_session / replay_unsupported / ...) propagate as rejections;
411
+ // fusion degrades that source and reports it via `sources` (design §4.e).
412
+ const replayFace: SidecarReplayFace = {
413
+ replay: async ({ sessionId, afterSeq }) => {
414
+ const events: SidecarEventFace[] = []
415
+ let cursor = afterSeq ?? 0
416
+ for (let page = 0; page < REPLAY_MAX_PAGES; page += 1) {
417
+ const result = await client.replay(sessionId, cursor, REPLAY_PAGE_LIMIT)
418
+ events.push(...result.events)
419
+ if (!result.truncated || result.lastSeq === null || result.lastSeq <= cursor) break
420
+ cursor = result.lastSeq
421
+ }
422
+ return events
423
+ },
424
+ }
425
+
426
+ // SessionQueryFace → ctx.sessionQuery, re-resolved on EVERY use through
427
+ // the reflect `get` (never a hard inject): dsh-session-query may mount
428
+ // late or never, and fusion degrades per call instead of pending.
429
+ const getSessionQuery = (): SessionQueryFace | null => {
430
+ const getter = (ctx as { get?: (name: string) => unknown }).get
431
+ if (typeof getter !== 'function') return null
432
+ const engine = getter.call(ctx, 'sessionQuery')
433
+ return engine === undefined || engine === null ? null : (engine as SessionQueryFace)
434
+ }
435
+
436
+ const buildFusion = (dshEvents: DshEventFace | null): FusionQuery =>
437
+ new FusionQuery({
438
+ store,
439
+ dshEvents,
440
+ getSessionQuery,
441
+ replay: replayFace,
442
+ })
443
+
444
+ // DshEventFace is bound through the lazy `sessions` inject below, but
445
+ // fusion must exist NOW (routes capture it). Holder pattern: construct
446
+ // sidecar-only first, swap in a feed-backed instance when dsh-session
447
+ // binds, swap back on release — so `getCapabilities().dshEvents` always
448
+ // reports the truth instead of a permanently-optimistic facade. The swap
449
+ // drops the old instance's bounded event rings; replay and the live
450
+ // stream repopulate them, so no timeline data is lost, only hints.
451
+ const fusionHolder = { current: buildFusion(null) }
452
+ const fusion: FusionApi = {
453
+ getUnifiedSessions: () => fusionHolder.current.getUnifiedSessions(),
454
+ getSessionTimeline: (sessionId, opts) =>
455
+ fusionHolder.current.getSessionTimeline(sessionId, opts),
456
+ getProjectGroups: (opts) => fusionHolder.current.getProjectGroups(opts),
457
+ getLineage: (sessionId) => fusionHolder.current.getLineage(sessionId),
458
+ searchSessions: (query, opts) => fusionHolder.current.searchSessions(query, opts),
459
+ getCapabilities: () => fusionHolder.current.getCapabilities(),
460
+ }
461
+
462
+ // policy=off still reconciles read-only against an externally managed
463
+ // daemon: off means "lifecycle is not ours", not "do not read data".
464
+ // The store face tees each subscribe-stream event into fusion's bounded
465
+ // ring (timeline hints) on its way into the session cache.
466
+ const reconciler = new Reconciler(
467
+ client,
468
+ {
469
+ applySnapshot: (rows) => {
470
+ store.applySnapshot(rows)
471
+ },
472
+ applyEvent: (ev) => {
473
+ store.applyEvent(ev)
474
+ fusionHolder.current.ingestSidecarEvent(ev)
475
+ },
476
+ setStreamHealth: (health) => {
477
+ store.setStreamHealth(health)
478
+ },
479
+ hasWorkingSessions: () => store.hasWorkingSessions(),
480
+ },
481
+ {
482
+ activeMs: config.stream.reconcileActiveMs,
483
+ idleMs: config.stream.reconcileIdleMs,
484
+ },
485
+ )
486
+ const supervisor = new DaemonSupervisor(
487
+ { ping: () => client.ping(), spawnDaemon, detectLaunchAgent, log },
488
+ { policy: config.daemon.policy, backoffLimit: config.daemon.backoffLimit },
489
+ )
490
+ // `effective` tracks the settings-resolved config once the settings
491
+ // namespace registers below; until then (and in compositions without
492
+ // dsh-settings) it IS the entry config.
493
+ let effective: Config = config
494
+ const guardOptions: GuardOptions = {
495
+ allowWriteActions: () => effective.inject.enabled,
496
+ }
497
+
498
+ // ------------------------------------------------- M2 injection assembly
499
+
500
+ // dsh in-process path (§4.d path one). `liveAgents` is bound by the lazy
501
+ // agents inject below; until then (and in compositions without dsh-agent)
502
+ // the face reports the path unavailable: `get` misses, `resume` rejects,
503
+ // and the executor surfaces an honest failure while send-cli keeps working.
504
+ // The same binding carries `create` for the M3 analysis engine.
505
+ let liveAgents: AgentsRegistryFace | null = null
506
+ const agentsFace: AgentsServiceFace = {
507
+ get: (sessionId) => liveAgents?.get(sessionId),
508
+ resume: (options) =>
509
+ liveAgents === null
510
+ ? Promise.reject(
511
+ new Error('dsh agents service is not available in this composition'),
512
+ )
513
+ : liveAgents.resume(options),
514
+ }
515
+ const dshExecutor = createDshInjectExecutor({
516
+ agents: agentsFace,
517
+ log,
518
+ pluginName: name,
519
+ })
520
+
521
+ // send-cli path (§4.d path two): adapt `ctx.subprocess.spawn` onto the
522
+ // executor's SpawnLike seam. stdin is a real pipe (the message travels
523
+ // via `--message-stdin`, never argv); the runtimeDir redirect flows via
524
+ // the same childEnv the daemon paths use, so send talks to the same
525
+ // daemon. `done` rejects only on spawn-level failures — exactly the
526
+ // `exited` contract — and `kill()` maps to the tree-scoped terminate.
527
+ const spawnSendCli: SpawnLike = (argv) => {
528
+ const handle = ctx.subprocess.spawn({
529
+ argv,
530
+ cwd: homedir(),
531
+ stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' },
532
+ graceMs: SEND_CLI_GRACE_MS,
533
+ env: childEnv,
534
+ })
535
+ // Dead-pipe writes must not throw asynchronously (adapter obligation);
536
+ // the authoritative failure surfaces through `exited`.
537
+ handle.stdin?.on('error', () => {})
538
+ return {
539
+ stdin: {
540
+ write: (chunk) => {
541
+ handle.stdin?.write(chunk)
542
+ },
543
+ end: () => {
544
+ handle.stdin?.end()
545
+ },
546
+ },
547
+ onStdout: (listener) => {
548
+ handle.stdout?.on('data', listener)
549
+ },
550
+ onStderr: (listener) => {
551
+ handle.stderr?.on('data', listener)
552
+ },
553
+ exited: handle.done.then((outcome) => outcome.exitCode),
554
+ kill: () => {
555
+ handle.terminate()
556
+ },
557
+ }
558
+ }
559
+ const sendCliExecutor = createSendCliExecutor({
560
+ spawn: spawnSendCli,
561
+ log,
562
+ opts: { command },
563
+ })
564
+
565
+ /** Live target re-check against the reconciled store (§4.f.5 prepare). */
566
+ const verifyTarget = async (target: InjectTarget): Promise<TargetStatus | null> => {
567
+ const view = store
568
+ .getBoardState()
569
+ .sessions.find(
570
+ (s) => s.agent === target.agent && s.session_id === target.sessionId,
571
+ )
572
+ if (view === undefined) return null
573
+ return {
574
+ agent: view.agent,
575
+ sessionId: view.session_id,
576
+ status: view.status,
577
+ title: view.title,
578
+ project: view.project,
579
+ }
580
+ }
581
+
582
+ // Constructed even when inject.enabled=false: `allowWrite` reads the live
583
+ // `effective` value on every prepare, and the route layer's
584
+ // guardWriteAction blocks first anyway (two independent gates, no
585
+ // duplication). Gateway audit entries are body-free by construction
586
+ // (byte size + sha256 prefix only), so forwarding them whole is S8-safe.
587
+ const injectGateway = new InjectGateway({
588
+ executors: { dsh: dshExecutor, sendCli: sendCliExecutor },
589
+ verifyTarget,
590
+ allowWrite: () => effective.inject.enabled,
591
+ log: (entry) => log(entry.ok ? 'info' : 'warn', `inject ${entry.phase}`, entry),
592
+ })
593
+
594
+ // -------------------------------------------------- M3 analysis assembly
595
+
596
+ // In-flight analysis sessions created through this assembly, tracked at
597
+ // the wiring layer (the engine keeps its bookkeeping private): every
598
+ // engine cleanup path goes through `handle.dispose()`, which unregisters
599
+ // here, so whatever is left when the plugin unloads is exactly the set
600
+ // the effect disposer must cancel (design: 在途分析会话随 dispose 清理).
601
+ const liveAnalysisSessions = new Set<AnalysisSession>()
602
+
603
+ /**
604
+ * Resolve the provider/model the analysis agent runs on (A-1 fix: an
605
+ * agent created without agentOptions has no model — `{{model}}` prompt
606
+ * assembly and `buildRequest` both fail, yielding an empty summary).
607
+ * Explicit `analysis.provider`+`analysis.model` config wins (both
608
+ * non-empty, read live); otherwise the host's default model selection is
609
+ * reused via `ctx.agentDefaultModel` — the same source dsh's own entry
610
+ * points (headless/apiproxy) read. `null` = no model anywhere: routes
611
+ * pre-reject `analysis.request` as `analysis_model_unconfigured`.
612
+ */
613
+ const resolveAnalysisModel = (): { provider: string; model: string } | null => {
614
+ const provider = effective.analysis.provider.trim()
615
+ const model = effective.analysis.model.trim()
616
+ if (provider !== '' && model !== '') return { provider, model }
617
+ const getter = (ctx as { get?: (name: string) => unknown }).get
618
+ if (typeof getter !== 'function') return null
619
+ const service = getter.call(ctx, 'agentDefaultModel') as
620
+ | AgentDefaultModelFace
621
+ | undefined
622
+ | null
623
+ if (service === undefined || service === null) return null
624
+ try {
625
+ const selection = service.currentSelection()
626
+ if (
627
+ typeof selection?.provider === 'string' &&
628
+ selection.provider !== '' &&
629
+ typeof selection.model === 'string' &&
630
+ selection.model !== ''
631
+ ) {
632
+ return { provider: selection.provider, model: selection.model }
633
+ }
634
+ } catch {
635
+ // A throwing selection reads as "no default available" — the routes'
636
+ // pre-check turns that into an honest analysis_model_unconfigured.
637
+ }
638
+ return null
639
+ }
640
+
641
+ const createAnalysisAgent: AnalysisAgentFace['create'] = async (options) => {
642
+ const agents = liveAgents
643
+ if (agents === null) {
644
+ // Raced past the routes' availability probe: surfaces as an honest
645
+ // create_failed result, never a crash.
646
+ throw new Error('dsh agents service is not available in this composition')
647
+ }
648
+ const selection = resolveAnalysisModel()
649
+ if (selection === null) {
650
+ // Raced past the routes' model pre-check (config/settings flipped
651
+ // mid-flight): surfaces as an honest create_failed, never an agent
652
+ // that assembles `{{model}}`-less prompts into empty summaries (A-1).
653
+ throw new Error(
654
+ 'no analysis model available: set analysis.provider/analysis.model or mount agentDefaultModel',
655
+ )
656
+ }
657
+ const handle = await agents.create({
658
+ ...options,
659
+ agentOptions: { provider: selection.provider, model: selection.model },
660
+ // The deployment persona's `{{cwd}}` variable reads session.header.cwd,
661
+ // which only meta.cwd populates — same as dsh-headless's own create
662
+ // call (A-1: without it prompt assembly errors and the summary is '').
663
+ meta: { cwd: process.cwd() },
664
+ })
665
+ const tracked: AnalysisSession = {
666
+ agent: handle.agent,
667
+ dispose: async () => {
668
+ liveAnalysisSessions.delete(tracked)
669
+ await handle.dispose()
670
+ },
671
+ }
672
+ liveAnalysisSessions.add(tracked)
673
+ return tracked
674
+ }
675
+
676
+ // Engine log entries are body-free by the engine's own contract (S8):
677
+ // kind/title/ids/outcomes/sizes only — safe to forward whole.
678
+ const analysisEngine = new AnalysisEngine({
679
+ createAgent: createAnalysisAgent,
680
+ allowAnalysis: () => effective.analysis.enabled,
681
+ log: (entry) =>
682
+ log(entry.errorCode !== undefined ? 'warn' : 'info', `analysis ${entry.op}`, entry),
683
+ })
684
+
685
+ /**
686
+ * Assemble the bounded AnalysisInput for one target from fusion data
687
+ * (design §4.e.3: summaries come from the fused timelines/overviews).
688
+ * `null` = target unknown to fusion → the routes answer 404. The user
689
+ * question rides the HEAD of the text so it survives the engine's
690
+ * tail truncation, and the session timeline lists NEWEST events first
691
+ * for the same reason: when the engine's head-keep truncation bites,
692
+ * it should shed the oldest — least informative — events (F5).
693
+ */
694
+ const buildAnalysisInput = async (
695
+ req: AnalysisTargetRequest,
696
+ ): Promise<AnalysisInput | null> => {
697
+ const questionLines =
698
+ req.question !== undefined && req.question.trim() !== ''
699
+ ? [
700
+ '[用户问题 / question]',
701
+ clampAnalysisText(req.question, ANALYSIS_QUESTION_CLAMP),
702
+ '',
703
+ ]
704
+ : []
705
+
706
+ if (req.targetKind === 'session') {
707
+ const targetId = req.targetId ?? ''
708
+ const session =
709
+ fusion.getUnifiedSessions().find((s) => s.sessionId === targetId) ?? null
710
+ if (session === null) return null
711
+ const page = await fusion.getSessionTimeline(targetId, {
712
+ limit: ANALYSIS_TIMELINE_LIMIT,
713
+ })
714
+ const sources = page.sources
715
+ const summaryText = [
716
+ ...questionLines,
717
+ `[会话概览 / session] agent=${session.agent} status=${session.status} live=${session.live}`,
718
+ `title: ${session.title !== '' ? clampAnalysisText(session.title) : '(untitled)'}`,
719
+ `project: ${session.project}`,
720
+ `last activity: ${new Date(session.lastActivityAt).toISOString()}`,
721
+ '',
722
+ `[时间线 / timeline,最新在前 / newest first] ${page.entries.length} events (sources: dshLive=${sources.dshLive} dshCold=${sources.dshCold} replay=${sources.sidecarReplay} buffer=${sources.sidecarBuffer})`,
723
+ ...[...page.entries].reverse().map(
724
+ (entry) =>
725
+ `- [${new Date(entry.ts).toISOString()}] ${entry.kind}` +
726
+ `${entry.seq !== null ? ` seq=${entry.seq}` : ''}` +
727
+ `${entry.text !== '' ? ` ${clampAnalysisText(entry.text)}` : ''}`,
728
+ ),
729
+ ].join('\n')
730
+ return {
731
+ kind: 'session',
732
+ title:
733
+ session.title !== '' ? session.title : `${session.agent} ${session.sessionId}`,
734
+ summaryText,
735
+ meta: { targetId, agent: session.agent },
736
+ }
737
+ }
738
+
739
+ if (req.targetKind === 'project') {
740
+ const wanted = normalizeAnalysisProject(req.targetId ?? '')
741
+ const group =
742
+ fusion
743
+ .getProjectGroups()
744
+ .find((g) => normalizeAnalysisProject(g.project) === wanted) ?? null
745
+ if (group === null) return null
746
+ const omitted = group.sessions.length - ANALYSIS_MAX_SESSIONS
747
+ const summaryText = [
748
+ ...questionLines,
749
+ `[项目概览 / project] ${group.project}`,
750
+ `agents: ${group.agents.join(', ')} | sessions: ${group.sessions.length} | last activity: ${new Date(group.lastActivityAt).toISOString()}`,
751
+ '',
752
+ ...group.sessions.slice(0, ANALYSIS_MAX_SESSIONS).map(describeUnifiedSession),
753
+ ...(omitted > 0 ? [`… ${omitted} more sessions omitted`] : []),
754
+ ].join('\n')
755
+ return {
756
+ kind: 'project',
757
+ title: `project ${group.project}`,
758
+ summaryText,
759
+ meta: { targetId: group.project },
760
+ }
761
+ }
762
+
763
+ // cross-agent: whole-board overview, always resolvable (possibly empty).
764
+ const groups = fusion.getProjectGroups()
765
+ const sessionsTotal = groups.reduce((n, g) => n + g.sessions.length, 0)
766
+ const omittedGroups = groups.length - ANALYSIS_MAX_GROUPS
767
+ const summaryText = [
768
+ ...questionLines,
769
+ `[跨 agent 概览 / cross-agent overview] ${groups.length} projects, ${sessionsTotal} sessions in the correlation window`,
770
+ '',
771
+ ...groups.slice(0, ANALYSIS_MAX_GROUPS).flatMap((group) => [
772
+ `[${group.project}] agents: ${group.agents.join(', ')} | sessions: ${group.sessions.length}`,
773
+ ...group.sessions.slice(0, ANALYSIS_CROSS_SESSIONS).map(describeUnifiedSession),
774
+ '',
775
+ ]),
776
+ ...(omittedGroups > 0 ? [`… ${omittedGroups} more projects omitted`] : []),
777
+ ].join('\n')
778
+ return { kind: 'cross-agent', title: 'cross-agent overview', summaryText }
779
+ }
780
+
781
+ const routes = createRoutes({
782
+ store,
783
+ supervisor,
784
+ guardOptions,
785
+ injectGateway,
786
+ fusion,
787
+ // The analysis write gate reads the LIVE setting, same posture as the
788
+ // inject gate; the engine's allowAnalysis reads the same value (two
789
+ // independent gates, no duplication).
790
+ analysisEnabled: () => effective.analysis.enabled,
791
+ analysis: {
792
+ engine: analysisEngine,
793
+ buildInput: buildAnalysisInput,
794
+ available: () => liveAgents !== null,
795
+ modelConfigured: () => resolveAnalysisModel() !== null,
796
+ },
797
+ log,
798
+ })
799
+
800
+ ctx.effect(() => {
801
+ const removeRoute = ctx.webServer.register({
802
+ kind: 'prefix',
803
+ path: API_PREFIX,
804
+ handler: routes.handle,
805
+ })
806
+ // Cold-start latency (M1 acceptance ②): the moment the supervisor
807
+ // confirms a reachable daemon — ADOPTED and HOSTED are both entered off
808
+ // a successful ping, so the socket exists — reconcile immediately
809
+ // instead of waiting out whatever poll the reconciler has pending.
810
+ const offStateChange = supervisor.onStateChange((state) => {
811
+ if (state === 'adopted' || state === 'hosted') void reconciler.reconcileNow()
812
+ })
813
+ fusionHolder.current.start()
814
+ reconciler.start()
815
+ supervisor.start()
816
+ return async () => {
817
+ offStateChange()
818
+ await supervisor.stop()
819
+ reconciler.stop()
820
+ // In-flight analysis sessions die with the plugin: dispose() stops
821
+ // the agent loop and removes the session (the UI "stop" semantics),
822
+ // bounding token burn across plugin reloads (§7-B / design risk 12).
823
+ await Promise.all(
824
+ [...liveAnalysisSessions].map((handle) => handle.dispose().catch(() => {})),
825
+ )
826
+ routes.dispose()
827
+ removeRoute()
828
+ // Whichever instance the holder points at by now (idempotent stop;
829
+ // a feed-bound instance is also stopped by its own inject release).
830
+ fusionHolder.current.stop()
831
+ }
832
+ }, 'agent-sidecar: host assembly (route + reconciler + supervisor + fusion + analysis)')
833
+
834
+ // Fusion dsh event feed binding (M3). Lazy inject on `sessions`
835
+ // (dsh-session's service key; the feed itself is the cordis event bus —
836
+ // `ctx.on('session/event' | 'session/created' | 'session/disposed')` —
837
+ // but only meaningful while the service is mounted, and gating on it
838
+ // keeps `getCapabilities().dshEvents.available` honest). Compositions
839
+ // without dsh-session simply never run this: fusion stays sidecar-only
840
+ // and every route keeps working (degradation, not failure).
841
+ ctx.inject(['sessions'], (injected) => {
842
+ const sctx = injected as HostContext
843
+ // 'session/*' keys live in dsh-session's Events augmentation, which
844
+ // this package deliberately does not import (structural-faces rule);
845
+ // the cast keeps the listener registration honest at runtime.
846
+ const bus = sctx as unknown as {
847
+ on(event: string, handler: (...args: never[]) => void): () => void
848
+ }
849
+ const feed: DshEventFace = {
850
+ on: (event: string, handler: (...args: never[]) => void) => bus.on(event, handler),
851
+ }
852
+ const withFeed = buildFusion(feed)
853
+ withFeed.start()
854
+ const previous = fusionHolder.current
855
+ fusionHolder.current = withFeed
856
+ previous.stop()
857
+ sctx.effect(() => () => {
858
+ // Service departing: swap back to a sidecar-only fusion so queries
859
+ // keep answering (and capabilities report the feed as gone).
860
+ const downgraded = buildFusion(null)
861
+ downgraded.start()
862
+ fusionHolder.current = downgraded
863
+ withFeed.stop()
864
+ }, 'agent-sidecar: fusion dsh feed release')
865
+ log('debug', 'fusion dsh event feed online (sessions service bound)')
866
+ })
867
+
868
+ // dsh injection + analysis path binding (M2/M3). Lazy inject, same
869
+ // pattern as settings below: `agents` (dsh-agent AgentRegistry) is
870
+ // present in every dsh-base composition, but a top-level hard inject
871
+ // would pend the whole fiber in agent-less compositions (see module
872
+ // doc). The callback rides its own fiber: cordis unloads and re-runs it
873
+ // whenever the service changes, and the effect disposer unbinds so the
874
+ // executor AND the analysis engine degrade cleanly again. Binding the
875
+ // service reference here (not per call) keeps resume/create's owner
876
+ // context on this fiber, so handles created for injection or analysis
877
+ // are drained by cordis if the plugin unloads.
878
+ ctx.inject(['agents'], (injected) => {
879
+ const actx = injected as HostContext & { agents: AgentsRegistryFace }
880
+ liveAgents = actx.agents
881
+ actx.effect(() => () => {
882
+ liveAgents = null
883
+ }, 'agent-sidecar: agents binding release')
884
+ log('debug', 'dsh inject + analysis paths online (agents service bound)')
885
+ })
886
+
887
+ // Skill path two (T6.2, design §7): register the embedded agent-sidecar
888
+ // skill provider. Lazy inject on `skills` (dsh-skill's service key):
889
+ // compositions without dsh-skill simply never run this — silent,
890
+ // capability-honest skip. Registration rides this callback's fiber, so
891
+ // cordis unregisters the provider on plugin unload / service departure
892
+ // (dsh-skill d.ts:243-244 "Fiber disposal unregisters the provider and
893
+ // invalidates catalog caches"); no manual effect wrapping, same posture
894
+ // as settings.register below. The gate reads the APPLY-TIME config value
895
+ // on purpose (restart semantics, documented in the schema description):
896
+ // a live settings flip cannot re-run this callback anyway.
897
+ ctx.inject(['skills'], (injected) => {
898
+ const sctx = injected as HostContext & { skills: SkillsServiceFace }
899
+ registerSidecarSkillProvider({
900
+ skills: sctx.skills,
901
+ provide: config.skill.provide,
902
+ log,
903
+ })
904
+ })
905
+
906
+ // Settings namespace 'agent-sidecar' (T2.4): pairs the browser settings
907
+ // card (keyed `settings.plugin.item` slot) and persists user edits into
908
+ // dsh's settings document. Lazy inject: compositions without dsh-settings
909
+ // simply never run this, and nothing else depends on it.
910
+ // `applies` (installed dsh-settings 0.1.1-rc.2, verified at source) is
911
+ // namespace-level UI-badge metadata surfaced through `describe()` only —
912
+ // commit() swaps the resolved value and notifies watchers regardless, so
913
+ // the `scope.watch → effective` chain below always takes effect
914
+ // immediately. `applies: 'live'` ('live' | 'restart' are the only
915
+ // values) is the honest badge for the security-relevant `inject.enabled`
916
+ // gate, which IS read live on every prepare/execute (M2 review F-1): a
917
+ // 'restart' badge would tell the user a gate they just closed is still
918
+ // open. Trade-off, documented: daemon.*/stream.*/sidecar values are
919
+ // baked into this assembly at apply time, so their edits still need a
920
+ // plugin reload despite the badge — a UX understatement, versus a badge
921
+ // that misstates a kill switch.
922
+ ctx.inject(['settings'], (injected) => {
923
+ try {
924
+ const sctx = injected as HostContext & { settings: SettingsServiceFace }
925
+ const scope = sctx.settings.register<Config>(name, Config, {
926
+ base: config,
927
+ applies: 'live',
928
+ })
929
+ effective = scope.get()
930
+ const unwatch = scope.watch((next) => {
931
+ effective = next
932
+ })
933
+ sctx.effect(() => () => {
934
+ unwatch()
935
+ effective = config
936
+ }, 'agent-sidecar: settings scope release')
937
+ log('debug', 'settings namespace registered', { applies: 'live' })
938
+ } catch (err) {
939
+ log('warn', `settings namespace registration failed: ${String(err)}`)
940
+ }
941
+ })
942
+
943
+ // Single startup line; the stable "host half assembled" marker is what the
944
+ // S0 triple evidence chain greps for (info does not reach the terminal in
945
+ // dsh 0.1.1-rc.2 web — s0_smoke.md F-4 — but the in-process log face and
946
+ // boot-completion probe both consume it).
947
+ ctx.logger.info(
948
+ `agent-sidecar: host half assembled (policy=${config.daemon.policy}, socket=${socketPath}, route=${API_PREFIX})`,
949
+ )
950
+ }