@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,484 @@
1
+ /**
2
+ * `/sidecar` slash command (design §4.c `ctx.commands` row / M2 交付,
3
+ * T4.6): a client-owned quick status overview of the sidecar — daemon
4
+ * state, connection health, working/waiting counts, and the top-N active
5
+ * sessions grouped by project — plus a pointer to the full board tab.
6
+ *
7
+ * MECHANISM (source audit, code-first): this dsh version has a CLIENT-side
8
+ * slash-command extension point. The web GUI's slash menu is served by the
9
+ * harness `ui-commands` client package, whose `CommandUiRuntime` service
10
+ * (registered as `commandUi`) accepts client-owned contributions:
11
+ *
12
+ * - `commandUi.register({ name, description, available, ui })` adds one
13
+ * slash-menu entry whose behavior lives entirely on the client (no host
14
+ * descriptor) — merged with the host catalog by name, collisions fail
15
+ * loud, duplicate contributions throw at registration
16
+ * (harness `packages/client/ui-commands/src/client/contract.ts` +
17
+ * `service.ts`; ecosystem precedent for the host-side flavor:
18
+ * `dsh-agent-teams/src/command.ts`).
19
+ * - The only `ui.kind` this dsh version supports is `popupSelect`:
20
+ * an async `options(session, signal)` provider plus `onSelect`. The
21
+ * `/sidecar` overview therefore presents as a popup card of rows — a
22
+ * read-only glance; every row's onSelect is a no-op.
23
+ *
24
+ * The `commandUi` service type is NOT part of the published plugin SDK
25
+ * (same situation as `settingsScope` in ./index.ts), so this module keeps
26
+ * STRUCTURAL mirrors of the harness contract, verified against the source
27
+ * above. Registration goes through `ctx.inject(['commandUi'], …)` — lazy,
28
+ * like the design's `ctx.commands` row: a composition without the service
29
+ * simply never gains the command.
30
+ *
31
+ * DATA: the overview reuses the existing client data layer (`fetchState`,
32
+ * ./api.ts) and the board's pure derivations (./board/logic.ts). No new
33
+ * backend endpoint. The snapshot→overview derivation is the pure, fully
34
+ * unit-testable {@link buildOverview}.
35
+ *
36
+ * Wiring note (S5 integration wave): call {@link registerSidecarCommand}
37
+ * from the client `apply`; this module performs no self-registration.
38
+ *
39
+ * @module
40
+ */
41
+
42
+ import { fetchState, isApiError } from './api.ts'
43
+ import type { RequestOptions, SessionView, StateSnapshot } from './api.ts'
44
+ import {
45
+ abbreviateSessionId,
46
+ agentGlyph,
47
+ countWorking,
48
+ deriveWidgetConnection,
49
+ groupSessions,
50
+ normalizeStatus,
51
+ } from './board/logic.ts'
52
+ import type {
53
+ DaemonStateToken,
54
+ SessionCardVM,
55
+ SessionStatusToken,
56
+ WidgetConnection,
57
+ } from './board/logic.ts'
58
+ import { tCommand } from './locales/command.ts'
59
+
60
+ /** The slash command name (without the leading slash). */
61
+ export const SIDECAR_COMMAND_NAME = 'sidecar'
62
+
63
+ /** Default cap on session rows listed in the overview (top-N truncation). */
64
+ export const DEFAULT_OVERVIEW_TOP_N = 5
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Overview model (the pure snapshot→structure derivation contract).
68
+ // ---------------------------------------------------------------------------
69
+
70
+ /** One session line of the overview. */
71
+ export interface OverviewSessionRow {
72
+ agent: string
73
+ /** Single-character agent marker (board vocabulary). */
74
+ glyph: string
75
+ sessionId: string
76
+ /** Head…tail abbreviation for display. */
77
+ shortId: string
78
+ status: SessionStatusToken
79
+ statusLabel: string
80
+ /** Session title with the untitled fallback applied. */
81
+ title: string
82
+ relativeTime: string
83
+ }
84
+
85
+ /** One project section of the overview (board grouping order). */
86
+ export interface OverviewProjectGroup {
87
+ /** Raw project path; '' for the unknown-project bucket. */
88
+ key: string
89
+ /** Display label (path basename, or the localized unknown-project name). */
90
+ label: string
91
+ /** Full path; '' when unknown. */
92
+ fullPath: string
93
+ rows: OverviewSessionRow[]
94
+ }
95
+
96
+ /** Guidance block shown when the sidecar is offline or unreachable. */
97
+ export interface OverviewGuidance {
98
+ kind: 'unreachable' | 'daemon-failed' | 'daemon-defer'
99
+ title: string
100
+ hint: string
101
+ }
102
+
103
+ /** The full overview structure `/sidecar` renders from. */
104
+ export interface OverviewModel {
105
+ /** False only when no snapshot could be fetched at all. */
106
+ reachable: boolean
107
+ /** Daemon supervisor state; null when unreachable. */
108
+ daemonState: DaemonStateToken | null
109
+ daemonLabel: string
110
+ connection: WidgetConnection
111
+ connectionLabel: string
112
+ workingCount: number
113
+ waitingCount: number
114
+ /** Every session in the snapshot (dead included). */
115
+ totalCount: number
116
+ /** Active (non-dead) sessions, grouped by project, capped at top N. */
117
+ groups: OverviewProjectGroup[]
118
+ /** Active sessions dropped by the top-N cap. */
119
+ truncatedCount: number
120
+ /** Non-null when offline/unreachable (design: 「sidecar 未连接」引导). */
121
+ guidance: OverviewGuidance | null
122
+ /** Pointer to the full board tab. */
123
+ boardHint: string
124
+ }
125
+
126
+ /** Tunables of {@link buildOverview} (all injectable for tests). */
127
+ export interface OverviewComputeOptions {
128
+ /** Clock (epoch ms); defaults to Date.now() at call time. */
129
+ nowMs?: number
130
+ /** Session-row cap; defaults to {@link DEFAULT_OVERVIEW_TOP_N}. */
131
+ topN?: number
132
+ }
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // Pure derivation helpers.
136
+ // ---------------------------------------------------------------------------
137
+
138
+ const MINUTE_MS = 60_000
139
+ const HOUR_MS = 3_600_000
140
+ const DAY_MS = 86_400_000
141
+
142
+ /**
143
+ * Coarse relative time over the command locale segment (same thresholds as
144
+ * the board's formatRelativeTime, which is bound to the zh-only board
145
+ * table and therefore not reused here).
146
+ */
147
+ function relativeTime(thenMs: number, nowMs: number): string {
148
+ if (!Number.isFinite(thenMs)) return ''
149
+ const delta = nowMs - thenMs
150
+ if (delta < MINUTE_MS) return tCommand('command.time.justNow')
151
+ if (delta < HOUR_MS) {
152
+ return tCommand('command.time.minutesAgo', { n: Math.floor(delta / MINUTE_MS) })
153
+ }
154
+ if (delta < DAY_MS) {
155
+ return tCommand('command.time.hoursAgo', { n: Math.floor(delta / HOUR_MS) })
156
+ }
157
+ return tCommand('command.time.daysAgo', { n: Math.floor(delta / DAY_MS) })
158
+ }
159
+
160
+ /** Wire SessionView → board card VM (epoch seconds → epoch ms boundary). */
161
+ function toCardVM(view: SessionView): SessionCardVM {
162
+ return {
163
+ agent: view.agent,
164
+ sessionId: view.session_id,
165
+ status: view.status,
166
+ title: view.title,
167
+ project: view.project,
168
+ updatedAtMs: view.updated_at * 1000,
169
+ lastEvent:
170
+ view.last_event === null
171
+ ? null
172
+ : { kind: view.last_event.kind, text: view.last_event.text },
173
+ gap: view.gap,
174
+ }
175
+ }
176
+
177
+ /** Card VM → overview row (labels resolved in the active locale). */
178
+ function toRow(card: SessionCardVM, nowMs: number): OverviewSessionRow {
179
+ const status = normalizeStatus(card.status)
180
+ const title = card.title.trim()
181
+ return {
182
+ agent: card.agent,
183
+ glyph: agentGlyph(card.agent),
184
+ sessionId: card.sessionId,
185
+ shortId: abbreviateSessionId(card.sessionId),
186
+ status,
187
+ statusLabel: tCommand(`command.status.${status}`),
188
+ title: title === '' ? tCommand('command.untitled') : title,
189
+ relativeTime: relativeTime(card.updatedAtMs, nowMs),
190
+ }
191
+ }
192
+
193
+ /** Offline/unreachable guidance for the given daemon situation. */
194
+ function deriveGuidance(daemonState: DaemonStateToken | null): OverviewGuidance | null {
195
+ if (daemonState === null) {
196
+ return {
197
+ kind: 'unreachable',
198
+ title: tCommand('command.unreachable'),
199
+ hint: tCommand('command.unreachableHint'),
200
+ }
201
+ }
202
+ if (daemonState === 'failed') {
203
+ return {
204
+ kind: 'daemon-failed',
205
+ title: tCommand('command.offlineFailed'),
206
+ hint: tCommand('command.offlineFailedHint'),
207
+ }
208
+ }
209
+ if (daemonState === 'defer') {
210
+ return {
211
+ kind: 'daemon-defer',
212
+ title: tCommand('command.offlineDefer'),
213
+ hint: tCommand('command.offlineDeferHint'),
214
+ }
215
+ }
216
+ return null
217
+ }
218
+
219
+ /**
220
+ * Pure snapshot→overview derivation. `null` means the state endpoint was
221
+ * unreachable (daemon offline / plugin host down): the model degrades to
222
+ * the 「sidecar 未连接」 guidance. A snapshot with daemon failed/defer keeps
223
+ * its (last-snapshot) sessions and counts — same honesty posture as the
224
+ * board's degraded banner — with the matching guidance attached.
225
+ *
226
+ * Grouping and ordering reuse the board's pure logic: groups by project
227
+ * (most recent first, unknown-project bucket last), cards by status rank
228
+ * then recency. The top-N cap walks groups in that order; groups beyond
229
+ * the cap are dropped and their sessions counted in `truncatedCount`.
230
+ * Dead sessions are never listed (the overview is an "active sessions"
231
+ * glance) but still count into `totalCount`.
232
+ */
233
+ export function buildOverview(
234
+ snapshot: StateSnapshot | null,
235
+ opts: OverviewComputeOptions = {},
236
+ ): OverviewModel {
237
+ const boardHint = tCommand('command.boardHint')
238
+ if (snapshot === null) {
239
+ return {
240
+ reachable: false,
241
+ daemonState: null,
242
+ daemonLabel: tCommand('command.daemon.unknown'),
243
+ connection: 'off',
244
+ connectionLabel: tCommand('command.connection.off'),
245
+ workingCount: 0,
246
+ waitingCount: 0,
247
+ totalCount: 0,
248
+ groups: [],
249
+ truncatedCount: 0,
250
+ guidance: deriveGuidance(null),
251
+ boardHint,
252
+ }
253
+ }
254
+
255
+ const nowMs = opts.nowMs ?? Date.now()
256
+ const topN = opts.topN ?? DEFAULT_OVERVIEW_TOP_N
257
+ const daemonState = snapshot.daemon.state
258
+ const sessions = snapshot.board.sessions
259
+ const connection = deriveWidgetConnection(daemonState, snapshot.board.streamHealth)
260
+
261
+ const active = sessions
262
+ .filter((view) => normalizeStatus(view.status) !== 'dead')
263
+ .map(toCardVM)
264
+
265
+ const groups: OverviewProjectGroup[] = []
266
+ let remaining = Math.max(0, topN)
267
+ let truncatedCount = 0
268
+ for (const group of groupSessions(active)) {
269
+ if (remaining <= 0) {
270
+ truncatedCount += group.cards.length
271
+ continue
272
+ }
273
+ const taken = group.cards.slice(0, remaining)
274
+ truncatedCount += group.cards.length - taken.length
275
+ remaining -= taken.length
276
+ groups.push({
277
+ key: group.key,
278
+ label: group.key === '' ? tCommand('command.unknownProject') : group.label,
279
+ fullPath: group.fullPath,
280
+ rows: taken.map((card) => toRow(card, nowMs)),
281
+ })
282
+ }
283
+
284
+ return {
285
+ reachable: true,
286
+ daemonState,
287
+ daemonLabel: tCommand(`command.daemon.${daemonState}`),
288
+ connection,
289
+ connectionLabel: tCommand(`command.connection.${connection}`),
290
+ workingCount: countWorking(sessions),
291
+ waitingCount: sessions.filter((view) => normalizeStatus(view.status) === 'waiting').length,
292
+ totalCount: sessions.length,
293
+ groups,
294
+ truncatedCount,
295
+ guidance: deriveGuidance(daemonState),
296
+ boardHint,
297
+ }
298
+ }
299
+
300
+ // ---------------------------------------------------------------------------
301
+ // popupSelect rendering (overview model → option rows).
302
+ // ---------------------------------------------------------------------------
303
+
304
+ /** Structural mirror of ui-commands `SelectOption` (contract.ts). */
305
+ export interface SidecarSelectOption {
306
+ readonly id: string
307
+ readonly label: string
308
+ readonly detail?: string
309
+ readonly active?: boolean
310
+ }
311
+
312
+ /**
313
+ * Render the overview as popupSelect rows (stable unique ids). Row order:
314
+ * daemon/connection status, offline guidance (when any), counts or the
315
+ * empty notice, session rows in group order (project carried in the
316
+ * detail line), the truncation marker, and the board-tab pointer.
317
+ */
318
+ export function overviewToOptions(model: OverviewModel): SidecarSelectOption[] {
319
+ const options: SidecarSelectOption[] = [
320
+ {
321
+ id: 'daemon',
322
+ label: tCommand('command.daemonRow', { state: model.daemonLabel }),
323
+ detail: model.connectionLabel,
324
+ },
325
+ ]
326
+ if (model.guidance !== null) {
327
+ options.push({
328
+ id: `guidance:${model.guidance.kind}`,
329
+ label: model.guidance.title,
330
+ detail: model.guidance.hint,
331
+ })
332
+ }
333
+ if (model.reachable) {
334
+ if (model.totalCount === 0) {
335
+ options.push({ id: 'empty', label: tCommand('command.noSessions') })
336
+ } else {
337
+ options.push({
338
+ id: 'counts',
339
+ label: tCommand('command.countsRow', {
340
+ working: model.workingCount,
341
+ waiting: model.waitingCount,
342
+ }),
343
+ detail: tCommand('command.countsDetail', { total: model.totalCount }),
344
+ })
345
+ }
346
+ for (const group of model.groups) {
347
+ for (const row of group.rows) {
348
+ options.push({
349
+ id: `session:${row.sessionId}`,
350
+ label: `${row.glyph} ${row.title}`,
351
+ detail: tCommand('command.sessionDetail', {
352
+ project: group.label,
353
+ status: row.statusLabel,
354
+ time: row.relativeTime,
355
+ }),
356
+ })
357
+ }
358
+ }
359
+ if (model.truncatedCount > 0) {
360
+ options.push({
361
+ id: 'truncated',
362
+ label: tCommand('command.truncated', { n: model.truncatedCount }),
363
+ })
364
+ }
365
+ }
366
+ options.push({ id: 'board', label: model.boardHint })
367
+ return options
368
+ }
369
+
370
+ // ---------------------------------------------------------------------------
371
+ // Command contribution (structural mirrors of the ui-commands contract).
372
+ // ---------------------------------------------------------------------------
373
+
374
+ /** Structural mirror of ui-commands `CommandUiSpec` (popupSelect kind). */
375
+ export interface SidecarCommandUiSpec {
376
+ readonly kind: 'popupSelect'
377
+ options(session: unknown, signal: AbortSignal): Promise<readonly SidecarSelectOption[]>
378
+ onSelect(option: SidecarSelectOption, session: unknown): void | Promise<void>
379
+ }
380
+
381
+ /** Structural mirror of ui-commands `CommandContribution`. */
382
+ export interface SidecarCommandContribution {
383
+ readonly name: string
384
+ readonly description: string
385
+ available(session: unknown): boolean
386
+ readonly ui: SidecarCommandUiSpec
387
+ }
388
+
389
+ /** The `commandUi.register` face this module consumes (contract.ts). */
390
+ export interface CommandRegistryFace {
391
+ register(contribution: SidecarCommandContribution): () => void
392
+ }
393
+
394
+ /** Injectable seams (tests fake the data layer and the clock). */
395
+ export interface SidecarCommandDeps {
396
+ /** State fetcher; defaults to the shared data layer's fetchState. */
397
+ fetchState?: (opts?: RequestOptions) => Promise<StateSnapshot>
398
+ /** Clock (epoch ms); defaults to Date.now. */
399
+ now?: () => number
400
+ /** Session-row cap; defaults to {@link DEFAULT_OVERVIEW_TOP_N}. */
401
+ topN?: number
402
+ }
403
+
404
+ /**
405
+ * Build the `/sidecar` client command contribution. `options` fetches a
406
+ * fresh snapshot per popup open; an abort (popup closed) propagates so the
407
+ * shell can drop the stale request, while every other failure degrades to
408
+ * the unreachable-guidance overview instead of throwing into the shell.
409
+ * `description` is a live getter so a locale switch after registration
410
+ * still reaches the slash menu's next candidate pass.
411
+ */
412
+ export function createSidecarCommandContribution(
413
+ deps: SidecarCommandDeps = {},
414
+ ): SidecarCommandContribution {
415
+ const doFetch = deps.fetchState ?? fetchState
416
+ const now = deps.now ?? Date.now
417
+ return {
418
+ name: SIDECAR_COMMAND_NAME,
419
+ get description(): string {
420
+ return tCommand('command.description')
421
+ },
422
+ // The glance works in every session state; nothing to gate on.
423
+ available: () => true,
424
+ ui: {
425
+ kind: 'popupSelect',
426
+ options: async (_session, signal) => {
427
+ let snapshot: StateSnapshot | null = null
428
+ try {
429
+ snapshot = await doFetch({ signal })
430
+ } catch (err) {
431
+ if (isApiError(err) && err.kind === 'aborted') throw err
432
+ console.error('agent-sidecar: /sidecar state fetch failed', err)
433
+ }
434
+ const overviewOpts: OverviewComputeOptions = { nowMs: now() }
435
+ if (deps.topN !== undefined) overviewOpts.topN = deps.topN
436
+ return overviewToOptions(buildOverview(snapshot, overviewOpts))
437
+ },
438
+ onSelect: () => {
439
+ // Read-only glance: rows are informational, selection is a no-op.
440
+ },
441
+ },
442
+ }
443
+ }
444
+
445
+ // ---------------------------------------------------------------------------
446
+ // Registration.
447
+ // ---------------------------------------------------------------------------
448
+
449
+ /**
450
+ * Minimal mount-context face ({@link registerSidecarCommand}); the real
451
+ * `ClientContext` satisfies it structurally (cordis `ctx.inject`).
452
+ */
453
+ export interface CommandMountContext {
454
+ inject(deps: readonly string[], callback: (ctx: unknown) => void): unknown
455
+ }
456
+
457
+ /**
458
+ * Register `/sidecar` once the `commandUi` service is available (lazy, per
459
+ * the design's `ctx.commands` consumption row — a composition without the
460
+ * slash-menu runtime simply never gains the command).
461
+ *
462
+ * Idempotency: the ui-commands registry throws on a duplicate contribution
463
+ * name; that throw is caught and logged, so a double apply (HMR re-apply
464
+ * before the old fiber unloads) degrades to a no-op instead of taking the
465
+ * client half down. Disposal is owned by the registry's effect on the
466
+ * injected fiber — unloading the plugin unregisters the command.
467
+ */
468
+ export function registerSidecarCommand(
469
+ ctx: CommandMountContext,
470
+ deps: SidecarCommandDeps = {},
471
+ ): void {
472
+ try {
473
+ ctx.inject(['commandUi'], (injected) => {
474
+ const { commandUi } = injected as { commandUi: CommandRegistryFace }
475
+ try {
476
+ commandUi.register(createSidecarCommandContribution(deps))
477
+ } catch (err) {
478
+ console.error('agent-sidecar: /sidecar command registration skipped', err)
479
+ }
480
+ })
481
+ } catch (err) {
482
+ console.error('agent-sidecar: commandUi injection failed', err)
483
+ }
484
+ }