@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,482 @@
1
+ /**
2
+ * Inject panel (design §5.1 view 3): the two-phase confirmation UI for
3
+ * message injection. Fully controlled and presentational — the component
4
+ * does NO data fetching; the integration layer supplies `onPrepare` /
5
+ * `onExecute` callbacks that speak to `POST <prefix>/action` and resolve
6
+ * with either the wire success shape or the data layer's normalized
7
+ * ApiError (matched structurally, never imported).
8
+ *
9
+ * Three zones, driven by the pure reducer in ./logic.ts:
10
+ *
11
+ * 1. editor — message textarea + live UTF-8 byte counter, queue/steer mode
12
+ * radios, target row, cursor-cli process-list warning (§4.d/S7) and the
13
+ * audit-fingerprint note (§5.3);
14
+ * 2. confirm — the prepare plan (live target-status snapshot + message
15
+ * digest), the 60s confirmToken countdown, and the deliberately
16
+ * restrained danger button;
17
+ * 3. result — delivered / failed (re-prepare offered) / unknown (terminal:
18
+ * the reducer itself refuses a reset, so no retry affordance can exist —
19
+ * S6 — and the copy points at the session to verify).
20
+ *
21
+ * `capability.inject === false` disables the whole panel with the
22
+ * "enable injection in Settings" note.
23
+ */
24
+
25
+ import { useEffect, useId, useReducer, useState } from 'react'
26
+ import type { ReactNode } from 'react'
27
+ import { t as defaultT } from '../locales/index.ts'
28
+ import type { SidecarLocaleKey } from '../locales/index.ts'
29
+ import css from './inject.module.css'
30
+ import {
31
+ byteUsage,
32
+ classifyExecuteResponse,
33
+ classifyPrepareResponse,
34
+ deriveEditorGate,
35
+ errorCopy,
36
+ initialPanelState,
37
+ messageInvalidCopy,
38
+ MODE_COPY,
39
+ noticeCopy,
40
+ reducePanel,
41
+ RESULT_COPY,
42
+ resultActions,
43
+ showsProcessListWarning,
44
+ tokenCountdown,
45
+ validateMessage,
46
+ } from './logic.ts'
47
+ import type {
48
+ ApiErrorLike,
49
+ CopyRef,
50
+ InjectMode,
51
+ InjectPlanView,
52
+ InjectResultView,
53
+ PanelEvent,
54
+ PanelExecuteRequest,
55
+ PanelPrepareRequest,
56
+ PrepareSuccess,
57
+ } from './logic.ts'
58
+
59
+ /** Translate seat the panel consumes (module-local t by default). */
60
+ export type InjectTranslate = (
61
+ key: SidecarLocaleKey,
62
+ params?: Record<string, unknown>,
63
+ ) => string
64
+
65
+ /** The session the panel injects into, as picked on the board/detail view. */
66
+ export interface InjectPanelTarget {
67
+ agent: string
68
+ sessionId: string
69
+ title?: string
70
+ }
71
+
72
+ export interface InjectPanelProps {
73
+ /** Server capability bits (from the state snapshot). */
74
+ capability: { inject: boolean }
75
+ /** Injection target; null renders the editor disabled with a hint. */
76
+ target: InjectPanelTarget | null
77
+ /** Mode preselected on mount (the inject.default-mode setting). */
78
+ defaultMode: InjectMode
79
+ /** Phase one: POST inject.prepare (resolve errors as values, don't throw). */
80
+ onPrepare(req: PanelPrepareRequest): Promise<PrepareSuccess | ApiErrorLike>
81
+ /** Phase two: POST inject.execute (resolve errors as values, don't throw). */
82
+ onExecute(req: PanelExecuteRequest): Promise<InjectResultView | ApiErrorLike>
83
+ /** Close/dismiss the panel (the hosting modal owns the lifecycle). */
84
+ onClose?(): void
85
+ /** Clock injection for the token countdown; defaults to Date.now. */
86
+ nowMs?: () => number
87
+ /** Locale seat override; defaults to the module-local table. */
88
+ t?: InjectTranslate
89
+ }
90
+
91
+ /** Countdown re-render cadence while a confirm token is live. */
92
+ const TICK_MS = 500
93
+
94
+ function renderCopy(t: InjectTranslate, copy: CopyRef): string {
95
+ return t(copy.key, copy.params)
96
+ }
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // Zone building blocks.
100
+ // ---------------------------------------------------------------------------
101
+
102
+ interface WarningsProps {
103
+ t: InjectTranslate
104
+ agent: string | null
105
+ }
106
+
107
+ /** cursor-cli process-list warning (gated) + the always-on audit note. */
108
+ function Warnings(props: WarningsProps): ReactNode {
109
+ return (
110
+ <>
111
+ {props.agent !== null && showsProcessListWarning(props.agent)
112
+ ? <p className={css['warnBar']} role="alert">{props.t('inject.argvWarning')}</p>
113
+ : null}
114
+ <p className={css['auditNote']}>{props.t('inject.auditNote')}</p>
115
+ </>
116
+ )
117
+ }
118
+
119
+ interface PlanBoxProps {
120
+ t: InjectTranslate
121
+ plan: InjectPlanView
122
+ }
123
+
124
+ /** Confirm-phase plan: live target snapshot + message digest. */
125
+ function PlanBox(props: PlanBoxProps): ReactNode {
126
+ const { t, plan } = props
127
+ const status = plan.targetStatus
128
+ const targetName = status.title !== undefined && status.title !== ''
129
+ ? status.title
130
+ : plan.target.sessionId
131
+ return (
132
+ <div className={css['planBox']}>
133
+ <div className={css['planRow']}>
134
+ <span className={css['planKey']}>{t('inject.planTargetLabel')}</span>
135
+ <span className={css['planValue']}>
136
+ <span className={css['agentTag']}>{plan.target.agent}</span>
137
+ <span className={css['planTitle']} title={plan.target.sessionId}>{targetName}</span>
138
+ </span>
139
+ </div>
140
+ <div className={css['planRow']}>
141
+ <span className={css['planKey']}>
142
+ {t('inject.planStatus', { status: status.status })}
143
+ </span>
144
+ </div>
145
+ <p className={css['observedNote']}>{t('inject.statusObservedNote')}</p>
146
+ <div className={css['planRow']}>
147
+ <span className={css['planKey']}>{t('inject.planModeLabel')}</span>
148
+ <span className={css['planValue']}>{t(MODE_COPY[plan.mode].label)}</span>
149
+ </div>
150
+ <div className={css['planPreview']}>
151
+ <span className={css['planKey']}>
152
+ {t('inject.planPreviewLabel', { bytes: plan.messagePreview.bytes })}
153
+ </span>
154
+ <pre className={css['preview']}>{plan.messagePreview.head}</pre>
155
+ </div>
156
+ </div>
157
+ )
158
+ }
159
+
160
+ // ---------------------------------------------------------------------------
161
+ // Panel.
162
+ // ---------------------------------------------------------------------------
163
+
164
+ /**
165
+ * Render the two-phase inject panel.
166
+ * @param props - capability, target, and the integration callbacks.
167
+ * @returns the panel.
168
+ */
169
+ export function InjectPanel(props: InjectPanelProps): ReactNode {
170
+ const t = props.t ?? defaultT
171
+ const now = props.nowMs ?? Date.now
172
+ const [state, dispatch] = useReducer(reducePanel, undefined, initialPanelState)
173
+ const [draft, setDraft] = useState('')
174
+ const [mode, setMode] = useState<InjectMode>(props.defaultMode)
175
+ const [clock, setClock] = useState(() => now())
176
+ const textareaId = useId()
177
+ const modeGroup = useId()
178
+
179
+ // Token countdown: refresh the display clock and let the reducer decide
180
+ // expiry, only while a confirm token is live.
181
+ const inConfirm = state.phase === 'confirm'
182
+ useEffect(() => {
183
+ if (!inConfirm) return
184
+ setClock(now())
185
+ const id = setInterval(() => {
186
+ const nowValue = now()
187
+ setClock(nowValue)
188
+ dispatch({ type: 'TICK', nowMs: nowValue })
189
+ }, TICK_MS)
190
+ return () => { clearInterval(id) }
191
+ // `now` is a stable prop (or Date.now); phase entry is the real trigger.
192
+ // eslint-disable-next-line react-hooks/exhaustive-deps
193
+ }, [inConfirm])
194
+
195
+ const validation = validateMessage(draft)
196
+ const gate = deriveEditorGate({
197
+ injectEnabled: props.capability.inject,
198
+ hasTarget: props.target !== null,
199
+ phase: state.phase,
200
+ validation,
201
+ })
202
+
203
+ const handlePrepare = async (): Promise<void> => {
204
+ const target = props.target
205
+ if (target === null || !gate.canPrepare) return
206
+ const message = draft
207
+ dispatch({ type: 'PREPARE_START', message, mode })
208
+ let event: PanelEvent
209
+ try {
210
+ const response = await props.onPrepare({
211
+ target: { agent: target.agent, sessionId: target.sessionId },
212
+ mode,
213
+ message,
214
+ })
215
+ event = classifyPrepareResponse(response)
216
+ } catch {
217
+ // Contract says resolve errors as values; a throw is still mapped to
218
+ // a retryable transport notice rather than a crashed panel.
219
+ event = { type: 'PREPARE_ERROR', code: 'unexpected_error' }
220
+ }
221
+ dispatch(event)
222
+ }
223
+
224
+ const handleExecute = async (): Promise<void> => {
225
+ if (state.phase !== 'confirm') return
226
+ const { requestId, confirmToken, message } = state
227
+ dispatch({ type: 'EXECUTE_START' })
228
+ let event: PanelEvent
229
+ try {
230
+ const response = await props.onExecute({ requestId, confirmToken, message })
231
+ event = classifyExecuteResponse(response)
232
+ } catch {
233
+ // The execute may already have been dispatched server-side: honest
234
+ // verdict is unknown — terminal, no retry (S6).
235
+ event = {
236
+ type: 'EXECUTE_RESULT',
237
+ result: { outcome: 'unknown', errorCode: 'unexpected_error' },
238
+ }
239
+ }
240
+ dispatch(event)
241
+ }
242
+
243
+ const closeButton = props.onClose !== undefined
244
+ ? (
245
+ <button type="button" className={css['btn']} onClick={props.onClose}>
246
+ {t('inject.close')}
247
+ </button>
248
+ )
249
+ : null
250
+
251
+ // ── whole-panel gate: injection capability off ─────────────────────────
252
+ if (!props.capability.inject) {
253
+ return (
254
+ <section className={css['panel']} aria-label={t('inject.title')}>
255
+ <header className={css['header']}>
256
+ <h3 className={css['title']}>{t('inject.title')}</h3>
257
+ </header>
258
+ <div className={css['body']}>
259
+ <p className={css['capabilityOff']} role="status">{t('inject.capabilityOff')}</p>
260
+ {closeButton !== null
261
+ ? <div className={css['footer']}>{closeButton}</div>
262
+ : null}
263
+ </div>
264
+ </section>
265
+ )
266
+ }
267
+
268
+ // ── zone 3: result ──────────────────────────────────────────────────────
269
+ if (state.phase === 'result') {
270
+ const { result } = state
271
+ const actions = resultActions(result.outcome)
272
+ const toneClass = result.outcome === 'delivered'
273
+ ? css['resultOk']
274
+ : result.outcome === 'failed'
275
+ ? css['resultFail']
276
+ : css['resultUnknown']
277
+ return (
278
+ <section className={css['panel']} aria-label={t('inject.title')}>
279
+ <header className={css['header']}>
280
+ <h3 className={css['title']}>{t('inject.title')}</h3>
281
+ </header>
282
+ <div className={css['body']}>
283
+ <p className={toneClass} role="status">{t(RESULT_COPY[result.outcome])}</p>
284
+ {result.outcome === 'failed' && result.errorCode !== undefined
285
+ ? (
286
+ <p className={css['resultDetail']}>
287
+ {renderCopy(t, errorCopy(result.errorCode))}
288
+ </p>
289
+ )
290
+ : null}
291
+ {result.replayed === true
292
+ ? <p className={css['resultDetail']}>{t('inject.resultReplayed')}</p>
293
+ : null}
294
+ <div className={css['footer']}>
295
+ {closeButton}
296
+ {actions.canReprepare
297
+ ? (
298
+ <button
299
+ type="button"
300
+ className={css['btnPrimary']}
301
+ onClick={() => { dispatch({ type: 'RESET' }) }}
302
+ >
303
+ {t('inject.reprepare')}
304
+ </button>
305
+ )
306
+ : null}
307
+ {result.outcome === 'delivered' && props.onClose === undefined
308
+ ? (
309
+ <button
310
+ type="button"
311
+ className={css['btnPrimary']}
312
+ onClick={() => { dispatch({ type: 'RESET' }) }}
313
+ >
314
+ {t('inject.done')}
315
+ </button>
316
+ )
317
+ : null}
318
+ </div>
319
+ </div>
320
+ </section>
321
+ )
322
+ }
323
+
324
+ // ── zone 2: confirm / executing ─────────────────────────────────────────
325
+ if (state.phase === 'confirm' || state.phase === 'executing') {
326
+ const executing = state.phase === 'executing'
327
+ const countdown = state.phase === 'confirm'
328
+ ? tokenCountdown(state.expiresAt, clock)
329
+ : null
330
+ return (
331
+ <section className={css['panel']} aria-label={t('inject.confirmTitle')}>
332
+ <header className={css['header']}>
333
+ <h3 className={css['title']}>{t('inject.confirmTitle')}</h3>
334
+ </header>
335
+ <div className={css['body']}>
336
+ <PlanBox t={t} plan={state.plan} />
337
+ <Warnings t={t} agent={state.plan.target.agent} />
338
+ {countdown !== null
339
+ ? (
340
+ <p className={css['countdown']} role="timer">
341
+ {t('inject.countdown', { seconds: countdown.seconds })}
342
+ </p>
343
+ )
344
+ : null}
345
+ <div className={css['footer']}>
346
+ <button
347
+ type="button"
348
+ className={css['btn']}
349
+ disabled={executing}
350
+ onClick={() => { dispatch({ type: 'CANCEL' }) }}
351
+ >
352
+ {t('inject.cancel')}
353
+ </button>
354
+ <button
355
+ type="button"
356
+ className={css['btnDanger']}
357
+ disabled={executing}
358
+ onClick={() => { void handleExecute() }}
359
+ >
360
+ {t(executing ? 'inject.executing' : 'inject.confirmExecute')}
361
+ </button>
362
+ </div>
363
+ </div>
364
+ </section>
365
+ )
366
+ }
367
+
368
+ // ── zone 1: editor (idle / preparing) ───────────────────────────────────
369
+ const preparing = state.phase === 'preparing'
370
+ const canEdit = props.target !== null && !preparing
371
+ const usage = byteUsage(validation.bytes)
372
+ const notice = state.phase === 'idle' ? state.notice : null
373
+ const showInvalid = !validation.ok && validation.code !== 'empty'
374
+
375
+ return (
376
+ <section className={css['panel']} aria-label={t('inject.title')}>
377
+ <header className={css['header']}>
378
+ <h3 className={css['title']}>{t('inject.title')}</h3>
379
+ </header>
380
+ <div className={css['body']}>
381
+ {notice !== null
382
+ ? (
383
+ <p
384
+ className={notice.kind === 'token_expired' ? css['noticeWarn'] : css['noticeError']}
385
+ role="alert"
386
+ >
387
+ {renderCopy(t, noticeCopy(notice))}
388
+ {notice.kind === 'prepare_rejected' && notice.detail !== undefined
389
+ ? <span className={css['noticeDetail']}> {notice.detail}</span>
390
+ : null}
391
+ </p>
392
+ )
393
+ : null}
394
+
395
+ <div className={css['targetRow']}>
396
+ <span className={css['planKey']}>{t('inject.targetLabel')}</span>
397
+ {props.target !== null
398
+ ? (
399
+ <span className={css['planValue']}>
400
+ <span className={css['agentTag']}>{props.target.agent}</span>
401
+ <span className={css['planTitle']} title={props.target.sessionId}>
402
+ {props.target.title !== undefined && props.target.title !== ''
403
+ ? props.target.title
404
+ : props.target.sessionId}
405
+ </span>
406
+ </span>
407
+ )
408
+ : <span className={css['noTarget']}>{t('inject.noTarget')}</span>}
409
+ </div>
410
+
411
+ <div className={css['field']}>
412
+ <label className={css['label']} htmlFor={textareaId}>
413
+ {t('inject.messageLabel')}
414
+ </label>
415
+ <textarea
416
+ id={textareaId}
417
+ className={css['textarea']}
418
+ value={draft}
419
+ placeholder={t('inject.messagePlaceholder')}
420
+ disabled={!canEdit}
421
+ rows={5}
422
+ onChange={(event) => { setDraft(event.target.value) }}
423
+ />
424
+ <div className={css['byteRow']}>
425
+ <div className={css['byteBar']} aria-hidden>
426
+ <div
427
+ className={usage.over
428
+ ? `${css['byteFill']} ${css['byteFillOver']}`
429
+ : css['byteFill']}
430
+ style={{ width: `${usage.ratio * 100}%` }}
431
+ />
432
+ </div>
433
+ <span className={usage.over ? css['byteTextOver'] : css['byteText']}>
434
+ {t('inject.byteCount', { bytes: usage.bytes, limit: usage.limit })}
435
+ </span>
436
+ </div>
437
+ {showInvalid && !validation.ok
438
+ ? (
439
+ <p className={css['invalid']} role="alert">
440
+ {renderCopy(t, messageInvalidCopy(validation))}
441
+ </p>
442
+ )
443
+ : null}
444
+ </div>
445
+
446
+ <fieldset className={css['modes']}>
447
+ <legend className={css['label']}>{t('inject.modeLabel')}</legend>
448
+ {(['queue', 'steer'] as const).map(option => (
449
+ <label key={option} className={css['modeOption']}>
450
+ <input
451
+ type="radio"
452
+ name={modeGroup}
453
+ value={option}
454
+ checked={mode === option}
455
+ disabled={!canEdit}
456
+ onChange={() => { setMode(option) }}
457
+ />
458
+ <span className={css['modeText']}>
459
+ <span className={css['modeLabel']}>{t(MODE_COPY[option].label)}</span>
460
+ <span className={css['modeHint']}>{t(MODE_COPY[option].hint)}</span>
461
+ </span>
462
+ </label>
463
+ ))}
464
+ </fieldset>
465
+
466
+ <Warnings t={t} agent={props.target?.agent ?? null} />
467
+
468
+ <div className={css['footer']}>
469
+ {closeButton}
470
+ <button
471
+ type="button"
472
+ className={css['btnPrimary']}
473
+ disabled={!gate.canPrepare}
474
+ onClick={() => { void handlePrepare() }}
475
+ >
476
+ {t(preparing ? 'inject.preparing' : 'inject.prepare')}
477
+ </button>
478
+ </div>
479
+ </div>
480
+ </section>
481
+ )
482
+ }