@shendeguize/dsh-agent-sidecar 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +167 -0
- package/cordis.patch.yml +10 -0
- package/lib/client.js +8062 -0
- package/lib/client.js.map +1 -0
- package/lib/index.d.ts +396 -0
- package/lib/index.js +4166 -0
- package/package.json +101 -0
- package/src/analysis.ts +782 -0
- package/src/bridge.ts +841 -0
- package/src/client/analysis/AnalysisPanel.tsx +191 -0
- package/src/client/analysis/analysis.module.css +183 -0
- package/src/client/analysis-glue.ts +331 -0
- package/src/client/api.ts +380 -0
- package/src/client/board/Board.tsx +214 -0
- package/src/client/board/board.module.css +302 -0
- package/src/client/board/logic.ts +556 -0
- package/src/client/board/project-view-logic.ts +361 -0
- package/src/client/board/project-view.module.css +307 -0
- package/src/client/board/project-view.tsx +189 -0
- package/src/client/board/strings.ts +112 -0
- package/src/client/commands.ts +484 -0
- package/src/client/controller.ts +360 -0
- package/src/client/css-modules.d.ts +11 -0
- package/src/client/detail/SessionDetail.tsx +270 -0
- package/src/client/detail/detail.module.css +433 -0
- package/src/client/detail/logic.ts +779 -0
- package/src/client/detail/strings.ts +98 -0
- package/src/client/detail/transport.ts +175 -0
- package/src/client/detail-glue.ts +397 -0
- package/src/client/detail-view.module.css +79 -0
- package/src/client/detail-view.tsx +233 -0
- package/src/client/dsh-tools/LineageTree.tsx +210 -0
- package/src/client/dsh-tools/SearchPanel.tsx +169 -0
- package/src/client/dsh-tools/dsh-tools.module.css +374 -0
- package/src/client/dsh-tools/logic.ts +596 -0
- package/src/client/dsh-tools/strings.ts +90 -0
- package/src/client/index.ts +315 -0
- package/src/client/inject/InjectPanel.tsx +482 -0
- package/src/client/inject/inject.module.css +446 -0
- package/src/client/inject/logic.ts +516 -0
- package/src/client/inject/overlay.module.css +22 -0
- package/src/client/inject-glue.ts +171 -0
- package/src/client/locales/command.ts +48 -0
- package/src/client/locales/en.ts +385 -0
- package/src/client/locales/index.ts +123 -0
- package/src/client/locales/zh.ts +402 -0
- package/src/client/m3-transport.ts +151 -0
- package/src/client/mount.tsx +307 -0
- package/src/client/project-glue.ts +134 -0
- package/src/client/search-glue.ts +143 -0
- package/src/client/settings-card.module.css +359 -0
- package/src/client/settings-card.tsx +565 -0
- package/src/client/settings-glue.ts +130 -0
- package/src/client/sidebar-tab.tsx +494 -0
- package/src/client/sse.ts +366 -0
- package/src/client/widget.tsx +80 -0
- package/src/config.ts +193 -0
- package/src/dsh-inject.ts +240 -0
- package/src/fusion.ts +988 -0
- package/src/guard.ts +274 -0
- package/src/index.ts +950 -0
- package/src/inject-gateway.ts +574 -0
- package/src/routes.ts +1133 -0
- package/src/send-cli.ts +340 -0
- package/src/session-store.ts +184 -0
- package/src/skills-provider.ts +293 -0
- package/src/supervisor.ts +463 -0
|
@@ -0,0 +1,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
|
+
}
|