@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,463 @@
1
+ /**
2
+ * Sidecar daemon lifecycle supervisor — the probe-adopt-else-host state
3
+ * machine of design §4.a (.local/tasks/make_dsh_mode/design/dsh_plugin_design.md).
4
+ *
5
+ * Pure dependency-injected module: imports nothing from cordis/dsh. The
6
+ * plugin entry (index.ts) wires the real seams — Unix-socket `ping`,
7
+ * `ctx.subprocess.spawn` running `<sidecarCommand> daemon run`, read-only
8
+ * LaunchAgent detection via `agent-sidecar service status` — and registers
9
+ * `stop()` inside a `ctx.effect` disposer.
10
+ *
11
+ * Ownership contract: the supervisor only ever terminates processes it
12
+ * spawned itself (HOSTING/HOSTED). Externally managed daemons (ADOPTED,
13
+ * DEFER) are never killed — teardown merely disconnects.
14
+ */
15
+
16
+ export type SupervisorState =
17
+ | 'probe'
18
+ | 'adopted'
19
+ | 'defer'
20
+ | 'reprobe'
21
+ | 'hosting'
22
+ | 'hosted'
23
+ | 'backoff'
24
+ | 'failed'
25
+
26
+ export type SupervisorPolicy = 'adopt-or-host' | 'adopt-only' | 'off'
27
+
28
+ export type LogLevel = 'debug' | 'info' | 'warn' | 'error'
29
+
30
+ /** Daemon self-description returned by the Unix-socket `ping` op. */
31
+ export interface PingInfo {
32
+ pid: number
33
+ version: string
34
+ http: {
35
+ enabled: boolean
36
+ host?: string
37
+ port?: number
38
+ }
39
+ }
40
+
41
+ /** Minimal handle over a spawned `daemon run` foreground child process. */
42
+ export interface DaemonProcess {
43
+ /** Settles once the process exits, for any reason. */
44
+ readonly exited: Promise<number | null>
45
+ /** Tree-scoped SIGTERM → grace → SIGKILL; safe to call more than once. */
46
+ terminate(): Promise<void>
47
+ }
48
+
49
+ /** Opaque timer handle so Node globals and injected fake timers interoperate. */
50
+ export type TimerHandle = unknown
51
+
52
+ export interface SupervisorDeps {
53
+ /** One socket ping round-trip; resolves null on any failure (no socket, timeout, bad payload). */
54
+ ping(): Promise<PingInfo | null>
55
+ /** Spawn the daemon as a supervised foreground child. */
56
+ spawnDaemon(): DaemonProcess
57
+ /** Read-only macOS LaunchAgent detection; true means launchd owns the daemon. */
58
+ detectLaunchAgent(): Promise<boolean>
59
+ log(level: LogLevel, msg: string, meta?: Record<string, unknown>): void
60
+ /** Injectable timer pair (fake timers in tests). Defaults to globals. */
61
+ setTimeout?(fn: () => void, ms: number): TimerHandle
62
+ clearTimeout?(handle: TimerHandle): void
63
+ }
64
+
65
+ export interface SupervisorOptions {
66
+ policy: SupervisorPolicy
67
+ /** Consecutive hosting failures that trip FAILED. Default 5. */
68
+ backoffLimit?: number
69
+ /** First backoff delay; doubles on each consecutive failure. Default 1000. */
70
+ backoffBaseMs?: number
71
+ /** Ceiling for the exponential backoff. Default 30000. */
72
+ backoffCapMs?: number
73
+ /** DEFER re-probe cadence. Default 5000. */
74
+ probeIntervalMs?: number
75
+ /** ADOPTED health re-ping cadence. Default 5000. */
76
+ adoptedRepingMs?: number
77
+ /** Consecutive ADOPTED ping misses before REPROBE. Default 3. */
78
+ adoptedFailureLimit?: number
79
+ /** HOSTING readiness window before the spawn counts as failed. Default 5000. */
80
+ hostReadyTimeoutMs?: number
81
+ /** Ping cadence while waiting for a hosted daemon to become ready. Default 500. */
82
+ hostReadyPingIntervalMs?: number
83
+ }
84
+
85
+ export type StateListener = (state: SupervisorState, previous: SupervisorState) => void
86
+
87
+ const defaultSetTimeout = (fn: () => void, ms: number): TimerHandle =>
88
+ globalThis.setTimeout(fn, ms)
89
+
90
+ const defaultClearTimeout = (handle: TimerHandle): void => {
91
+ globalThis.clearTimeout(handle as ReturnType<typeof globalThis.setTimeout>)
92
+ }
93
+
94
+ const describeError = (error: unknown): string =>
95
+ error instanceof Error ? error.message : String(error)
96
+
97
+ export class DaemonSupervisor {
98
+ private readonly deps: SupervisorDeps
99
+ private readonly opts: Required<SupervisorOptions>
100
+
101
+ private _state: SupervisorState = 'probe'
102
+ private _lastPing: PingInfo | null = null
103
+ private readonly listeners = new Set<StateListener>()
104
+ private readonly timers = new Set<TimerHandle>()
105
+ /** Only ever non-null for a process this supervisor spawned itself. */
106
+ private proc: DaemonProcess | null = null
107
+ /**
108
+ * Invalidation token for async continuations (ping/detect results, process
109
+ * exit watchers): each macro transition bumps it, so continuations started
110
+ * under an older epoch abandon instead of acting on a stale world.
111
+ */
112
+ private epoch = 0
113
+ private started = false
114
+ private stopped = false
115
+ /** Consecutive hosting failures (readiness timeout or early exit). */
116
+ private hostFailures = 0
117
+ /** Consecutive ADOPTED re-ping misses. */
118
+ private pingFailures = 0
119
+
120
+ constructor(deps: SupervisorDeps, options: SupervisorOptions) {
121
+ this.deps = deps
122
+ this.opts = {
123
+ policy: options.policy,
124
+ backoffLimit: options.backoffLimit ?? 5,
125
+ backoffBaseMs: options.backoffBaseMs ?? 1000,
126
+ backoffCapMs: options.backoffCapMs ?? 30_000,
127
+ probeIntervalMs: options.probeIntervalMs ?? 5000,
128
+ adoptedRepingMs: options.adoptedRepingMs ?? 5000,
129
+ adoptedFailureLimit: options.adoptedFailureLimit ?? 3,
130
+ hostReadyTimeoutMs: options.hostReadyTimeoutMs ?? 5000,
131
+ hostReadyPingIntervalMs: options.hostReadyPingIntervalMs ?? 500,
132
+ }
133
+ }
134
+
135
+ get state(): SupervisorState {
136
+ return this._state
137
+ }
138
+
139
+ /** Last successful ping payload; null until the daemon answered once. */
140
+ get lastPing(): PingInfo | null {
141
+ return this._lastPing
142
+ }
143
+
144
+ /** Subscribe to state transitions; returns an unsubscribe function. */
145
+ onStateChange(listener: StateListener): () => void {
146
+ this.listeners.add(listener)
147
+ return () => {
148
+ this.listeners.delete(listener)
149
+ }
150
+ }
151
+
152
+ start(): void {
153
+ if (this.started || this.stopped) return
154
+ this.started = true
155
+ if (this.opts.policy === 'off') {
156
+ // Terminal quiescence: no probing, no timers, no spawn. DEFER is the
157
+ // closest state in the vocabulary to "lifecycle is not ours to manage".
158
+ this.deps.log('info', 'daemon management policy is off; supervisor standing down')
159
+ this.setState('defer')
160
+ return
161
+ }
162
+ void this.runDetermination('probe')
163
+ }
164
+
165
+ /**
166
+ * Tear everything down for the ctx.effect disposer: probe/backoff timers
167
+ * first, then terminate a self-spawned daemon. Adopted or launchd-managed
168
+ * daemons are never touched. Idempotent.
169
+ */
170
+ async stop(): Promise<void> {
171
+ if (this.stopped) return
172
+ this.stopped = true
173
+ this.epoch += 1
174
+ this.clearAllTimers()
175
+ this.listeners.clear()
176
+ const proc = this.proc
177
+ this.proc = null
178
+ if (proc) {
179
+ this.deps.log('info', 'stopping supervisor: terminating self-hosted daemon')
180
+ try {
181
+ await proc.terminate()
182
+ } catch (error) {
183
+ this.deps.log('warn', 'terminate during stop failed', { error: describeError(error) })
184
+ }
185
+ }
186
+ }
187
+
188
+ /** From FAILED only: reset the failure budget and re-run the determination. */
189
+ retry(): void {
190
+ if (this.stopped) return
191
+ if (this._state !== 'failed') {
192
+ this.deps.log('debug', 'retry ignored outside FAILED state', { state: this._state })
193
+ return
194
+ }
195
+ this.hostFailures = 0
196
+ this.pingFailures = 0
197
+ this.deps.log('info', 'retry requested: failure budget reset, re-probing')
198
+ void this.runDetermination('probe')
199
+ }
200
+
201
+ // ---------------------------------------------------------------- probe
202
+
203
+ /** Shared PROBE/REPROBE determination: ping → adopt, else LaunchAgent → defer, else host. */
204
+ private async runDetermination(entry: 'probe' | 'reprobe'): Promise<void> {
205
+ const ep = ++this.epoch
206
+ this.clearAllTimers()
207
+ this.setState(entry)
208
+ const info = await this.safePing()
209
+ if (this.invalidated(ep)) return
210
+ if (info) {
211
+ this.enterAdopted(info)
212
+ return
213
+ }
214
+ let managed = false
215
+ try {
216
+ managed = await this.deps.detectLaunchAgent()
217
+ } catch (error) {
218
+ this.deps.log('warn', 'LaunchAgent detection failed; assuming absent', {
219
+ error: describeError(error),
220
+ })
221
+ }
222
+ if (this.invalidated(ep)) return
223
+ if (managed) {
224
+ this.enterDefer('LaunchAgent installed; launchd owns daemon liveness')
225
+ return
226
+ }
227
+ if (this.opts.policy === 'adopt-only') {
228
+ this.enterDefer('policy adopt-only forbids spawning')
229
+ return
230
+ }
231
+ this.enterHosting()
232
+ }
233
+
234
+ // -------------------------------------------------------------- adopted
235
+
236
+ private enterAdopted(info: PingInfo): void {
237
+ this.clearAllTimers()
238
+ this._lastPing = info
239
+ this.pingFailures = 0
240
+ this.hostFailures = 0
241
+ this.deps.log('info', 'adopted existing daemon', { pid: info.pid, version: info.version })
242
+ this.setState('adopted')
243
+ this.scheduleAdoptedPing()
244
+ }
245
+
246
+ private scheduleAdoptedPing(): void {
247
+ this.schedule(this.opts.adoptedRepingMs, () => {
248
+ void this.adoptedPing()
249
+ })
250
+ }
251
+
252
+ private async adoptedPing(): Promise<void> {
253
+ const ep = this.epoch
254
+ const info = await this.safePing()
255
+ if (this.invalidated(ep) || this._state !== 'adopted') return
256
+ if (info) {
257
+ this._lastPing = info
258
+ this.pingFailures = 0
259
+ this.scheduleAdoptedPing()
260
+ return
261
+ }
262
+ this.pingFailures += 1
263
+ this.deps.log('warn', 'adopted daemon missed ping', {
264
+ misses: this.pingFailures,
265
+ limit: this.opts.adoptedFailureLimit,
266
+ })
267
+ if (this.pingFailures >= this.opts.adoptedFailureLimit) {
268
+ this.pingFailures = 0
269
+ void this.runDetermination('reprobe')
270
+ return
271
+ }
272
+ this.scheduleAdoptedPing()
273
+ }
274
+
275
+ // ---------------------------------------------------------------- defer
276
+
277
+ /** External management (launchd, or adopt-only policy): re-probe periodically, never spawn. */
278
+ private enterDefer(reason: string): void {
279
+ this.clearAllTimers()
280
+ this.deps.log('info', 'deferring daemon management', { reason })
281
+ this.setState('defer')
282
+ this.scheduleDeferProbe()
283
+ }
284
+
285
+ private scheduleDeferProbe(): void {
286
+ this.schedule(this.opts.probeIntervalMs, () => {
287
+ void this.deferProbe()
288
+ })
289
+ }
290
+
291
+ private async deferProbe(): Promise<void> {
292
+ const ep = this.epoch
293
+ const info = await this.safePing()
294
+ if (this.invalidated(ep) || this._state !== 'defer') return
295
+ if (info) {
296
+ this.enterAdopted(info)
297
+ return
298
+ }
299
+ this.scheduleDeferProbe()
300
+ }
301
+
302
+ // -------------------------------------------------------------- hosting
303
+
304
+ private enterHosting(): void {
305
+ const ep = ++this.epoch
306
+ this.clearAllTimers()
307
+ this.setState('hosting')
308
+ let proc: DaemonProcess
309
+ try {
310
+ proc = this.deps.spawnDaemon()
311
+ } catch (error) {
312
+ this.deps.log('error', 'failed to spawn daemon', { error: describeError(error) })
313
+ this.enterBackoff('spawn-error', null)
314
+ return
315
+ }
316
+ this.proc = proc
317
+ // The watcher survives into HOSTED (same epoch); any later transition
318
+ // bumps the epoch and thereby detaches it.
319
+ proc.exited.then(
320
+ (code) => {
321
+ if (this.invalidated(ep)) return
322
+ this.proc = null
323
+ this.deps.log('warn', 'hosted daemon exited', { code, state: this._state })
324
+ this.enterBackoff('daemon-exit', code)
325
+ },
326
+ (error: unknown) => {
327
+ if (this.invalidated(ep)) return
328
+ this.proc = null
329
+ this.deps.log('warn', 'hosted daemon exit watch failed', { error: describeError(error) })
330
+ this.enterBackoff('daemon-exit', null)
331
+ },
332
+ )
333
+ this.schedule(this.opts.hostReadyTimeoutMs, () => {
334
+ if (this.invalidated(ep)) return
335
+ this.deps.log('warn', 'hosted daemon readiness timeout', {
336
+ timeoutMs: this.opts.hostReadyTimeoutMs,
337
+ })
338
+ this.disposeProc()
339
+ this.enterBackoff('ready-timeout', null)
340
+ })
341
+ this.scheduleReadyPoll(ep)
342
+ }
343
+
344
+ private scheduleReadyPoll(ep: number): void {
345
+ this.schedule(this.opts.hostReadyPingIntervalMs, () => {
346
+ void this.readyPoll(ep)
347
+ })
348
+ }
349
+
350
+ private async readyPoll(ep: number): Promise<void> {
351
+ if (this.invalidated(ep)) return
352
+ const info = await this.safePing()
353
+ if (this.invalidated(ep) || this._state !== 'hosting') return
354
+ if (info) {
355
+ this.enterHosted(info)
356
+ return
357
+ }
358
+ this.scheduleReadyPoll(ep)
359
+ }
360
+
361
+ private enterHosted(info: PingInfo): void {
362
+ // Deliberately no epoch bump: the process exit watcher from enterHosting
363
+ // must stay attached so a crash in HOSTED still lands in BACKOFF.
364
+ this.clearAllTimers()
365
+ this._lastPing = info
366
+ this.hostFailures = 0
367
+ this.pingFailures = 0
368
+ this.deps.log('info', 'hosted daemon ready', { pid: info.pid, version: info.version })
369
+ this.setState('hosted')
370
+ }
371
+
372
+ // -------------------------------------------------------------- backoff
373
+
374
+ private enterBackoff(reason: string, code: number | null): void {
375
+ this.epoch += 1
376
+ this.clearAllTimers()
377
+ this.hostFailures += 1
378
+ this.setState('backoff')
379
+ if (this.hostFailures >= this.opts.backoffLimit) {
380
+ this.deps.log('error', 'hosting failure budget exhausted; giving up', {
381
+ failures: this.hostFailures,
382
+ reason,
383
+ })
384
+ this.setState('failed')
385
+ return
386
+ }
387
+ const delayMs = Math.min(
388
+ this.opts.backoffBaseMs * 2 ** (this.hostFailures - 1),
389
+ this.opts.backoffCapMs,
390
+ )
391
+ this.deps.log('warn', 'hosting failed; backing off', {
392
+ reason,
393
+ code,
394
+ failures: this.hostFailures,
395
+ delayMs,
396
+ })
397
+ // Re-run the full determination after the delay: if a daemon appeared
398
+ // externally in the meantime we adopt it instead of fighting the
399
+ // single-instance lock with another doomed spawn.
400
+ this.schedule(delayMs, () => {
401
+ void this.runDetermination('probe')
402
+ })
403
+ }
404
+
405
+ // -------------------------------------------------------------- helpers
406
+
407
+ private invalidated(ep: number): boolean {
408
+ return this.stopped || this.epoch !== ep
409
+ }
410
+
411
+ private async safePing(): Promise<PingInfo | null> {
412
+ try {
413
+ return await this.deps.ping()
414
+ } catch (error) {
415
+ this.deps.log('debug', 'ping threw; treating as unreachable', {
416
+ error: describeError(error),
417
+ })
418
+ return null
419
+ }
420
+ }
421
+
422
+ /** Fire-and-forget terminate of the self-spawned process (readiness timeout path). */
423
+ private disposeProc(): void {
424
+ const proc = this.proc
425
+ this.proc = null
426
+ if (!proc) return
427
+ proc.terminate().catch((error: unknown) => {
428
+ this.deps.log('warn', 'terminate failed', { error: describeError(error) })
429
+ })
430
+ }
431
+
432
+ private setState(next: SupervisorState): void {
433
+ if (this._state === next) return
434
+ const previous = this._state
435
+ this._state = next
436
+ this.deps.log('debug', 'supervisor state transition', { from: previous, to: next })
437
+ for (const listener of [...this.listeners]) {
438
+ try {
439
+ listener(next, previous)
440
+ } catch (error) {
441
+ this.deps.log('warn', 'state listener threw', { error: describeError(error) })
442
+ }
443
+ }
444
+ }
445
+
446
+ private schedule(ms: number, fn: () => void): TimerHandle {
447
+ const set = this.deps.setTimeout ?? defaultSetTimeout
448
+ let handle: TimerHandle
449
+ handle = set(() => {
450
+ this.timers.delete(handle)
451
+ if (this.stopped) return
452
+ fn()
453
+ }, ms)
454
+ this.timers.add(handle)
455
+ return handle
456
+ }
457
+
458
+ private clearAllTimers(): void {
459
+ const clear = this.deps.clearTimeout ?? defaultClearTimeout
460
+ for (const handle of this.timers) clear(handle)
461
+ this.timers.clear()
462
+ }
463
+ }