@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,191 @@
1
+ /**
2
+ * AI bypass-analysis panel (T5.10b, design §4.e.3 / §5.1 view 2): fully
3
+ * controlled presentation over an {@link AnalysisGlueState} — the owner
4
+ * (detail view) holds the AnalysisStore and passes state + intents down.
5
+ *
6
+ * Honesty posture (§5.3): the engine disclaimer (fallback copy when a
7
+ * settle never happened) renders with every result, truncation is called
8
+ * out per exchange, terminal errors carry the vocabulary code, and the
9
+ * capability-off state explains where to enable analysis instead of
10
+ * hiding the entry. Strings ride the main locale table (`analysis.*`).
11
+ *
12
+ * @module
13
+ */
14
+
15
+ import { useState } from 'react'
16
+ import type { ReactElement } from 'react'
17
+ import { t } from '../locales/index.ts'
18
+ import type { AnalysisExchange, AnalysisGlueState } from '../analysis-glue.ts'
19
+ import css from './analysis.module.css'
20
+
21
+ export interface AnalysisPanelProps {
22
+ /** Live `analysis.enabled` bit (settings scope); false disables intents. */
23
+ enabled: boolean
24
+ state: AnalysisGlueState
25
+ onStart: () => void
26
+ onFollowup: (question: string) => void
27
+ onStop: () => void
28
+ onClose: () => void
29
+ }
30
+
31
+ /** Terminal failure code → locale key ('' falls back to the generic). */
32
+ const ERROR_KEYS: Record<string, Parameters<typeof t>[0]> = {
33
+ analysis_disabled: 'analysis.errDisabled',
34
+ analysis_unavailable: 'analysis.errUnavailable',
35
+ target_not_found: 'analysis.errTargetNotFound',
36
+ too_many_active: 'analysis.errTooManyActive',
37
+ timeout: 'analysis.errTimeout',
38
+ create_failed: 'analysis.errCreateFailed',
39
+ cancelled: 'analysis.errCancelled',
40
+ network_error: 'analysis.errNetwork',
41
+ request_timeout: 'analysis.errNetwork',
42
+ }
43
+
44
+ function errorText(code: string): string {
45
+ const key = ERROR_KEYS[code]
46
+ return key !== undefined ? t(key) : t('analysis.errGeneric', { code })
47
+ }
48
+
49
+ /** Retryable notice code → copy. */
50
+ function noticeText(code: string): string {
51
+ if (code === 'timeout') return t('analysis.noticeTimeout')
52
+ if (code === 'cancel_failed') return t('analysis.noticeCancelFailed')
53
+ return t('analysis.noticeNetwork')
54
+ }
55
+
56
+ function Exchange(props: { exchange: AnalysisExchange }): ReactElement {
57
+ const { exchange } = props
58
+ return (
59
+ <div className={css['exchange']} data-testid="agent-sidecar-analysis-exchange">
60
+ <span className={css['exchangeLabel']}>
61
+ {exchange.question === null ? t('analysis.exchangeInitial') : t('analysis.followupLabel')}
62
+ </span>
63
+ {exchange.question !== null && <div className={css['question']}>{exchange.question}</div>}
64
+ <pre className={css['summary']}>
65
+ {exchange.summary === '' ? t('analysis.emptySummary') : exchange.summary}
66
+ </pre>
67
+ {exchange.truncated && (
68
+ <span className={css['truncated']}>{t('analysis.truncatedNotice')}</span>
69
+ )}
70
+ </div>
71
+ )
72
+ }
73
+
74
+ /** The analysis panel. Pure render over props (state machine lives in glue). */
75
+ export function AnalysisPanel(props: AnalysisPanelProps): ReactElement {
76
+ const { enabled, state } = props
77
+ const [question, setQuestion] = useState('')
78
+
79
+ const conversationLive = state.phase === 'ready' || state.phase === 'answering'
80
+ const showStart = state.phase === 'idle' || state.phase === 'failed' || state.phase === 'stopped'
81
+
82
+ const submitFollowup = (): void => {
83
+ const q = question.trim()
84
+ if (q === '' || state.phase !== 'ready') return
85
+ setQuestion('')
86
+ props.onFollowup(q)
87
+ }
88
+
89
+ return (
90
+ <section className={css['panel']} data-testid="agent-sidecar-analysis">
91
+ <div className={css['head']}>
92
+ <span className={css['title']}>{t('analysis.title')}</span>
93
+ <span className={css['spacer']} />
94
+ {conversationLive && (
95
+ <button
96
+ type="button"
97
+ className={css['textButton']}
98
+ disabled={!enabled}
99
+ onClick={props.onStop}
100
+ data-testid="agent-sidecar-analysis-stop"
101
+ >
102
+ {t('analysis.stop')}
103
+ </button>
104
+ )}
105
+ <button type="button" className={css['closeButton']} onClick={props.onClose}>
106
+ {t('analysis.close')}
107
+ </button>
108
+ </div>
109
+
110
+ {!enabled && (
111
+ <div className={css['noteCard']} data-testid="agent-sidecar-analysis-disabled">
112
+ {t('analysis.disabledNote')}
113
+ </div>
114
+ )}
115
+
116
+ {state.exchanges.map((exchange, index) => (
117
+ <Exchange key={index} exchange={exchange} />
118
+ ))}
119
+
120
+ {state.phase === 'failed' && state.errorCode !== null && (
121
+ <div className={css['errorCard']} data-testid="agent-sidecar-analysis-error">
122
+ {errorText(state.errorCode)}
123
+ </div>
124
+ )}
125
+ {state.phase === 'stopped' && (
126
+ <div className={css['noteCard']}>{t('analysis.stopped')}</div>
127
+ )}
128
+ {state.noticeCode !== null && (
129
+ <div className={css['noticeBar']} data-testid="agent-sidecar-analysis-notice">
130
+ {noticeText(state.noticeCode)}
131
+ </div>
132
+ )}
133
+
134
+ {enabled && showStart && (
135
+ <>
136
+ {state.phase === 'idle' && (
137
+ <div className={css['mutedLine']}>{t('analysis.idleHint')}</div>
138
+ )}
139
+ <button
140
+ type="button"
141
+ className={css['primaryButton']}
142
+ onClick={props.onStart}
143
+ data-testid="agent-sidecar-analysis-start"
144
+ >
145
+ {state.phase === 'idle' ? t('analysis.start') : t('analysis.restart')}
146
+ </button>
147
+ </>
148
+ )}
149
+
150
+ {state.phase === 'requesting' && (
151
+ <div className={css['mutedLine']}>{t('analysis.requesting')}</div>
152
+ )}
153
+ {state.phase === 'answering' && (
154
+ <div className={css['mutedLine']}>{t('analysis.answering')}</div>
155
+ )}
156
+
157
+ {conversationLive && (
158
+ <div className={css['followupForm']}>
159
+ <input
160
+ className={css['followupInput']}
161
+ value={question}
162
+ placeholder={t('analysis.followupPlaceholder')}
163
+ disabled={!enabled || state.phase !== 'ready'}
164
+ onChange={(event) => {
165
+ setQuestion(event.currentTarget.value)
166
+ }}
167
+ onKeyDown={(event) => {
168
+ if (event.key === 'Enter') submitFollowup()
169
+ }}
170
+ data-testid="agent-sidecar-analysis-question"
171
+ />
172
+ <button
173
+ type="button"
174
+ className={css['textButton']}
175
+ disabled={!enabled || state.phase !== 'ready' || question.trim() === ''}
176
+ onClick={submitFollowup}
177
+ data-testid="agent-sidecar-analysis-followup"
178
+ >
179
+ {t('analysis.followupSubmit')}
180
+ </button>
181
+ </div>
182
+ )}
183
+
184
+ {(conversationLive || state.exchanges.length > 0) && (
185
+ <div className={css['disclaimer']} data-testid="agent-sidecar-analysis-disclaimer">
186
+ {state.disclaimer ?? t('analysis.disclaimerFallback')}
187
+ </div>
188
+ )}
189
+ </section>
190
+ )
191
+ }
@@ -0,0 +1,183 @@
1
+ /*
2
+ * AI bypass-analysis panel styles (design §4.e.3 / §5.1 view 2): every
3
+ * color rides a --dsw-alias-* design token with a neutral fallback — no
4
+ * theme colors are hardcoded as the only value. Same posture as
5
+ * dsh-tools.module.css.
6
+ */
7
+
8
+ .panel {
9
+ display: flex;
10
+ flex-direction: column;
11
+ gap: 8px;
12
+ min-width: 0;
13
+ padding: 10px 12px;
14
+ border-radius: 10px;
15
+ border: 1px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.12));
16
+ background: var(--dsw-alias-bg-layer-1, rgba(0, 0, 0, 0.02));
17
+ color: var(--dsw-alias-label-primary, #1f2328);
18
+ }
19
+
20
+ .head {
21
+ display: flex;
22
+ align-items: center;
23
+ gap: 8px;
24
+ min-width: 0;
25
+ }
26
+
27
+ .title {
28
+ font-size: 13px;
29
+ font-weight: 600;
30
+ color: var(--dsw-alias-label-primary, #1f2328);
31
+ }
32
+
33
+ .spacer {
34
+ flex: 1;
35
+ }
36
+
37
+ .closeButton,
38
+ .textButton {
39
+ flex: none;
40
+ font-size: 12px;
41
+ padding: 2px 8px;
42
+ border-radius: 6px;
43
+ border: 1px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.12));
44
+ background: transparent;
45
+ color: var(--dsw-alias-label-secondary, #57606a);
46
+ cursor: pointer;
47
+ }
48
+
49
+ .closeButton:hover,
50
+ .textButton:hover {
51
+ background: var(--dsw-alias-bg-layer-2, rgba(0, 0, 0, 0.04));
52
+ }
53
+
54
+ .primaryButton {
55
+ align-self: flex-start;
56
+ font-size: 12px;
57
+ font-weight: 600;
58
+ padding: 4px 12px;
59
+ border-radius: 6px;
60
+ border: 1px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.12));
61
+ background: var(--dsw-alias-bg-layer-2, rgba(0, 0, 0, 0.04));
62
+ color: var(--dsw-alias-label-primary, #1f2328);
63
+ cursor: pointer;
64
+ }
65
+
66
+ .primaryButton:disabled,
67
+ .textButton:disabled {
68
+ opacity: 0.5;
69
+ cursor: not-allowed;
70
+ }
71
+
72
+ .mutedLine {
73
+ font-size: 12px;
74
+ line-height: 18px;
75
+ color: var(--dsw-alias-label-secondary, #57606a);
76
+ }
77
+
78
+ /* Capability-off / terminal-error note. */
79
+ .noteCard {
80
+ font-size: 12px;
81
+ line-height: 18px;
82
+ padding: 8px 10px;
83
+ border-radius: 8px;
84
+ border: 1px dashed var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.12));
85
+ background: var(--dsw-alias-bg-layer-1, rgba(0, 0, 0, 0.02));
86
+ color: var(--dsw-alias-label-secondary, #57606a);
87
+ }
88
+
89
+ .errorCard {
90
+ font-size: 12px;
91
+ line-height: 18px;
92
+ padding: 8px 10px;
93
+ border-radius: 8px;
94
+ color: var(--dsw-alias-state-error-primary, #cf222e);
95
+ background: var(--dsw-alias-state-error-secondary, rgba(207, 34, 46, 0.08));
96
+ border: 1px solid var(--dsw-alias-state-error-secondary, rgba(207, 34, 46, 0.24));
97
+ }
98
+
99
+ /* Non-terminal retryable notice. */
100
+ .noticeBar {
101
+ font-size: 12px;
102
+ line-height: 18px;
103
+ padding: 6px 10px;
104
+ border-radius: 8px;
105
+ color: var(--dsw-alias-state-warning-primary, #9a6700);
106
+ background: var(--dsw-alias-state-warning-secondary, rgba(154, 103, 0, 0.1));
107
+ border: 1px solid var(--dsw-alias-state-warning-secondary, rgba(154, 103, 0, 0.28));
108
+ }
109
+
110
+ .exchange {
111
+ display: flex;
112
+ flex-direction: column;
113
+ gap: 4px;
114
+ }
115
+
116
+ .exchangeLabel {
117
+ font-size: 11px;
118
+ font-weight: 600;
119
+ color: var(--dsw-alias-label-secondary, #57606a);
120
+ }
121
+
122
+ .question {
123
+ font-size: 12px;
124
+ line-height: 18px;
125
+ padding: 6px 10px;
126
+ border-radius: 8px;
127
+ background: var(--dsw-alias-bg-layer-2, rgba(0, 0, 0, 0.04));
128
+ color: var(--dsw-alias-label-primary, #1f2328);
129
+ white-space: pre-wrap;
130
+ word-break: break-word;
131
+ }
132
+
133
+ .summary {
134
+ margin: 0;
135
+ font-size: 12px;
136
+ line-height: 18px;
137
+ padding: 8px 10px;
138
+ border-radius: 8px;
139
+ background: var(--dsw-alias-bg-layer-1, rgba(0, 0, 0, 0.02));
140
+ border: 1px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.12));
141
+ color: var(--dsw-alias-label-primary, #1f2328);
142
+ white-space: pre-wrap;
143
+ word-break: break-word;
144
+ font-family: inherit;
145
+ max-height: 320px;
146
+ overflow: auto;
147
+ }
148
+
149
+ .truncated {
150
+ font-size: 11px;
151
+ color: var(--dsw-alias-state-warning-primary, #9a6700);
152
+ }
153
+
154
+ /* The §5.3 honesty line — always visible with results. */
155
+ .disclaimer {
156
+ font-size: 11px;
157
+ line-height: 16px;
158
+ color: var(--dsw-alias-label-secondary, #57606a);
159
+ border-top: 1px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.08));
160
+ padding-top: 6px;
161
+ }
162
+
163
+ .followupForm {
164
+ display: flex;
165
+ gap: 6px;
166
+ }
167
+
168
+ .followupInput {
169
+ flex: 1;
170
+ min-width: 0;
171
+ font-size: 12px;
172
+ padding: 4px 8px;
173
+ border-radius: 6px;
174
+ border: 1px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.16));
175
+ background: var(--dsw-alias-bg-layer-0, #fff);
176
+ color: var(--dsw-alias-label-primary, #1f2328);
177
+ }
178
+
179
+ .actionsRow {
180
+ display: flex;
181
+ align-items: center;
182
+ gap: 8px;
183
+ }
@@ -0,0 +1,331 @@
1
+ /**
2
+ * AI bypass-analysis data glue (T5.10b, design §4.e.3 / §5.1): a
3
+ * framework-free store per analysis conversation, driving the controlled
4
+ * AnalysisPanel over the `POST action` `analysis.*` trio (host contract:
5
+ * routes.ts handleAnalysis* over analysis.ts AnalysisEngine).
6
+ *
7
+ * Wire semantics this store encodes:
8
+ * - a settled engine result comes back with the HTTP status derived from
9
+ * its `errorCode` (timeout→504, too_many_active→429, create_failed→502,
10
+ * cancelled→200); api.ts postAction folds non-200 bodies into ApiError
11
+ * with `reason` = that code — so failures arrive here as ApiError and
12
+ * the cancelled-terminal arrives as a 200 body;
13
+ * - the write gate answers 403 `analysis_disabled` (fail-closed), an
14
+ * agents-less composition answers 501 `analysis_unavailable`, and a
15
+ * pre-analysis host answers 400 `unknown_action` (mapped to the same
16
+ * honest "unavailable" terminal);
17
+ * - a request timeout DISPOSES the engine session (terminal), a followup
18
+ * timeout KEEPS it (non-terminal notice; analysis.ts contract).
19
+ *
20
+ * Token honesty: this store dials analysis turns only on explicit user
21
+ * intent (start / followup / stop) — never automatically. The one
22
+ * automatic call is dispose()'s fire-and-forget cancel, which spends no
23
+ * tokens and only releases the engine session slot (F2).
24
+ *
25
+ * @module
26
+ */
27
+
28
+ import {
29
+ isApiError,
30
+ postAction,
31
+ type AnalysisTargetKind,
32
+ type RequestOptions,
33
+ } from './api.ts'
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Wire mirror (host: analysis.ts AnalysisResult; hand-written, client
37
+ // program cannot import host modules).
38
+ // ---------------------------------------------------------------------------
39
+
40
+ /** Terminal fact about one analysis turn (host: analysis.ts). */
41
+ export type AnalysisOutcomeWire = 'completed' | 'failed' | 'cancelled' | 'timeout'
42
+
43
+ /** Body of a settled `analysis.request` / `analysis.followup` action. */
44
+ export interface AnalysisResultWire {
45
+ outcome: AnalysisOutcomeWire
46
+ analysisSessionId?: string
47
+ summary?: string
48
+ truncated: boolean
49
+ tokensHint?: number
50
+ errorCode?: string
51
+ detail?: string
52
+ disclaimer: string
53
+ }
54
+
55
+ /** Analysis target selector (mirrors routes.ts AnalysisTargetRequest). */
56
+ export interface AnalysisTarget {
57
+ targetKind: AnalysisTargetKind
58
+ targetId?: string
59
+ question?: string
60
+ }
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // Envelope builders (exported pure for tests).
64
+ // ---------------------------------------------------------------------------
65
+
66
+ export function analysisRequestEnvelope(target: AnalysisTarget): {
67
+ type: 'analysis.request'
68
+ targetKind: AnalysisTargetKind
69
+ targetId?: string
70
+ question?: string
71
+ } {
72
+ return {
73
+ type: 'analysis.request',
74
+ targetKind: target.targetKind,
75
+ ...(target.targetId !== undefined ? { targetId: target.targetId } : {}),
76
+ ...(target.question !== undefined && target.question.trim() !== ''
77
+ ? { question: target.question }
78
+ : {}),
79
+ }
80
+ }
81
+
82
+ export function analysisFollowupEnvelope(analysisSessionId: string, question: string): {
83
+ type: 'analysis.followup'
84
+ analysisSessionId: string
85
+ question: string
86
+ } {
87
+ return { type: 'analysis.followup', analysisSessionId, question }
88
+ }
89
+
90
+ export function analysisCancelEnvelope(analysisSessionId: string): {
91
+ type: 'analysis.cancel'
92
+ analysisSessionId: string
93
+ } {
94
+ return { type: 'analysis.cancel', analysisSessionId }
95
+ }
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // State shapes.
99
+ // ---------------------------------------------------------------------------
100
+
101
+ /**
102
+ * Conversation phases. `failed` is terminal for the conversation (restart
103
+ * mints a new engine session); `stopped` is the user-cancelled terminal.
104
+ */
105
+ export type AnalysisPhase =
106
+ | 'idle'
107
+ | 'requesting'
108
+ | 'ready'
109
+ | 'answering'
110
+ | 'stopped'
111
+ | 'failed'
112
+
113
+ /** One settled turn: the initial summary (question null) or a follow-up. */
114
+ export interface AnalysisExchange {
115
+ question: string | null
116
+ summary: string
117
+ truncated: boolean
118
+ tokensHint: number | null
119
+ }
120
+
121
+ export interface AnalysisGlueState {
122
+ phase: AnalysisPhase
123
+ analysisSessionId: string | null
124
+ exchanges: AnalysisExchange[]
125
+ /** Engine disclaimer verbatim; null before the first settle. */
126
+ disclaimer: string | null
127
+ /** Terminal failure code (phase 'failed'), else null. */
128
+ errorCode: string | null
129
+ /** Non-terminal notice code (kept session; retryable), else null. */
130
+ noticeCode: string | null
131
+ }
132
+
133
+ const INITIAL_STATE: AnalysisGlueState = {
134
+ phase: 'idle',
135
+ analysisSessionId: null,
136
+ exchanges: [],
137
+ disclaimer: null,
138
+ errorCode: null,
139
+ noticeCode: null,
140
+ }
141
+
142
+ /**
143
+ * The action deadline: the engine bounds one turn at ~60s (analysis.ts
144
+ * `turnTimeoutMs`), so the transport must outlive it to receive the
145
+ * engine's own timeout verdict instead of racing it.
146
+ */
147
+ export const ANALYSIS_POST_TIMEOUT_MS = 75_000
148
+
149
+ /** ApiError reason codes that keep a live followup session usable. */
150
+ const RETRYABLE_FOLLOWUP_CODES = new Set([
151
+ 'timeout', // engine turn timeout: session kept by contract
152
+ 'request_timeout',
153
+ 'network_error',
154
+ ])
155
+
156
+ function failureCode(err: unknown): string {
157
+ if (isApiError(err)) {
158
+ // A pre-analysis host rejects the envelope as unknown_action — same
159
+ // honest terminal as an assembled-but-agents-less host.
160
+ return err.reason === 'unknown_action' ? 'analysis_unavailable' : err.reason
161
+ }
162
+ return 'network_error'
163
+ }
164
+
165
+ // ---------------------------------------------------------------------------
166
+ // The store.
167
+ // ---------------------------------------------------------------------------
168
+
169
+ export interface AnalysisStoreOptions {
170
+ postActionFn?: typeof postAction
171
+ timeoutMs?: number
172
+ }
173
+
174
+ export class AnalysisStore {
175
+ private state: AnalysisGlueState = INITIAL_STATE
176
+ private readonly listeners = new Set<() => void>()
177
+ private readonly postActionFn: typeof postAction
178
+ private readonly timeoutMs: number
179
+ private disposed = false
180
+
181
+ constructor(options: AnalysisStoreOptions = {}) {
182
+ this.postActionFn = options.postActionFn ?? postAction
183
+ this.timeoutMs = options.timeoutMs ?? ANALYSIS_POST_TIMEOUT_MS
184
+ }
185
+
186
+ subscribe = (fn: () => void): (() => void) => {
187
+ this.listeners.add(fn)
188
+ return () => { this.listeners.delete(fn) }
189
+ }
190
+
191
+ getState = (): AnalysisGlueState => this.state
192
+
193
+ private setState(patch: Partial<AnalysisGlueState>): void {
194
+ if (this.disposed) return
195
+ this.state = { ...this.state, ...patch }
196
+ for (const fn of [...this.listeners]) fn()
197
+ }
198
+
199
+ private post(body: Parameters<typeof postAction>[0]): Promise<unknown> {
200
+ const opts: RequestOptions = { timeoutMs: this.timeoutMs }
201
+ return this.postActionFn(body, opts)
202
+ }
203
+
204
+ /** Start one analysis (allowed from idle and from the terminal phases). */
205
+ async start(target: AnalysisTarget): Promise<void> {
206
+ const { phase } = this.state
207
+ if (this.disposed || phase === 'requesting' || phase === 'ready' || phase === 'answering') {
208
+ return
209
+ }
210
+ this.setState({ ...INITIAL_STATE, phase: 'requesting' })
211
+ try {
212
+ const result = (await this.post(analysisRequestEnvelope(target))) as AnalysisResultWire
213
+ if (this.disposed) {
214
+ // The view unmounted while the request was in flight: the engine
215
+ // session it just created has no owner left — release it so it
216
+ // cannot strand one of the engine's bounded session slots (F2).
217
+ const id = result?.analysisSessionId
218
+ if (result?.outcome === 'completed' && typeof id === 'string' && id !== '') {
219
+ this.fireCancel(id)
220
+ }
221
+ return
222
+ }
223
+ if (this.state.phase !== 'requesting') return
224
+ this.adoptResult(null, result)
225
+ } catch (err) {
226
+ if (this.disposed || this.state.phase !== 'requesting') return
227
+ // Request-path failures are all terminal: either the engine session
228
+ // was never created, or (engine timeout) it was already disposed.
229
+ this.setState({ phase: 'failed', errorCode: failureCode(err) })
230
+ }
231
+ }
232
+
233
+ /** Ask a follow-up in the live analysis session. */
234
+ async followup(question: string): Promise<void> {
235
+ const id = this.state.analysisSessionId
236
+ const q = question.trim()
237
+ if (this.disposed || this.state.phase !== 'ready' || id === null || q === '') return
238
+ this.setState({ phase: 'answering', noticeCode: null })
239
+ // Widened re-reads: setState/stop() move the phase between awaits, and
240
+ // TS's property narrowing from the entry guard would misjudge that.
241
+ const phaseNow = (): AnalysisPhase => this.state.phase
242
+ try {
243
+ const result = (await this.post(analysisFollowupEnvelope(id, q))) as AnalysisResultWire
244
+ if (this.disposed || phaseNow() !== 'answering') return
245
+ this.adoptResult(q, result)
246
+ } catch (err) {
247
+ if (this.disposed || phaseNow() !== 'answering') return
248
+ const code = failureCode(err)
249
+ if (RETRYABLE_FOLLOWUP_CODES.has(code)) {
250
+ // The engine keeps the session on a turn timeout; transport-level
251
+ // failures are unknowable, so stay usable and let the user retry.
252
+ this.setState({ phase: 'ready', noticeCode: code })
253
+ } else {
254
+ this.setState({ phase: 'failed', errorCode: code })
255
+ }
256
+ }
257
+ }
258
+
259
+ /** Release the analysis session (idempotent on the engine side). */
260
+ async stop(): Promise<void> {
261
+ const id = this.state.analysisSessionId
262
+ const { phase } = this.state
263
+ if (this.disposed || id === null || (phase !== 'ready' && phase !== 'answering')) return
264
+ try {
265
+ await this.post(analysisCancelEnvelope(id))
266
+ if (this.disposed) return
267
+ // An in-flight followup now settles against a stopped phase and is
268
+ // dropped by the phase recheck above.
269
+ this.setState({ phase: 'stopped', noticeCode: null })
270
+ } catch {
271
+ if (this.disposed) return
272
+ this.setState({ noticeCode: 'cancel_failed' })
273
+ }
274
+ }
275
+
276
+ /** Fold one settled 200 result into the conversation. */
277
+ private adoptResult(question: string | null, result: AnalysisResultWire): void {
278
+ if (result.outcome === 'completed') {
279
+ this.setState({
280
+ phase: 'ready',
281
+ analysisSessionId: result.analysisSessionId ?? this.state.analysisSessionId,
282
+ exchanges: [
283
+ ...this.state.exchanges,
284
+ {
285
+ question,
286
+ summary: result.summary ?? '',
287
+ truncated: result.truncated,
288
+ tokensHint: result.tokensHint ?? null,
289
+ },
290
+ ],
291
+ disclaimer: result.disclaimer,
292
+ errorCode: null,
293
+ noticeCode: null,
294
+ })
295
+ return
296
+ }
297
+ // A 200 non-completed result is the engine's `cancelled` verdict (all
298
+ // other failure codes ride non-200 statuses); treat unknown shapes as
299
+ // terminal too — never invent a usable session.
300
+ this.setState({
301
+ phase: result.errorCode === 'cancelled' || result.outcome === 'cancelled'
302
+ ? 'stopped'
303
+ : 'failed',
304
+ errorCode: result.errorCode ?? result.outcome,
305
+ disclaimer: result.disclaimer,
306
+ })
307
+ }
308
+
309
+ /**
310
+ * Late settlements become no-ops; subscribers are dropped. Idempotent.
311
+ * A live engine session is released with a fire-and-forget cancel so
312
+ * unmount / session-switch remounts cannot strand `maxActiveSessions`
313
+ * slots until plugin unload (F2). Cancel is idempotent on the engine
314
+ * side; a transport failure is silently ignored (nothing to surface —
315
+ * the store is gone).
316
+ */
317
+ dispose(): void {
318
+ if (this.disposed) return
319
+ const { analysisSessionId, phase } = this.state
320
+ this.disposed = true
321
+ this.listeners.clear()
322
+ if (analysisSessionId !== null && (phase === 'ready' || phase === 'answering')) {
323
+ this.fireCancel(analysisSessionId)
324
+ }
325
+ }
326
+
327
+ /** Fire-and-forget analysis.cancel (failures are deliberately silent). */
328
+ private fireCancel(analysisSessionId: string): void {
329
+ void this.post(analysisCancelEnvelope(analysisSessionId)).catch(() => {})
330
+ }
331
+ }