@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,782 @@
1
+ /**
2
+ * AI bypass-analysis engine — design §5 pillar 3 / §4.e.3 (M3, T5.6).
3
+ * Creates a DEDICATED dsh analysis session per request via `ctx.agents.create`,
4
+ * feeds it a bounded summary of the observed session/project, supports
5
+ * incremental follow-up questions, and can be cancelled from the UI at any
6
+ * time. Pure engine: routing/index wiring is the integration layer's job, and
7
+ * the analysis INPUT (summary text assembled from fusion timelines/overviews)
8
+ * arrives as a structured parameter — this module never touches fusion.
9
+ *
10
+ * API facts verified against the installed SDK
11
+ * (`@deepseek-ai/dsh-agent@0.1.1-rc.2` d.ts, authoritative over the design
12
+ * sketch):
13
+ *
14
+ * - `ctx.agents.create(options): Promise<AgentHandle>` (lib/types/index.d.ts:288)
15
+ * with `CreateAgentOptions` requiring a CALLER-SUPPLIED `sessionId`
16
+ * (index.d.ts:65-118) — the engine mints one per analysis — plus an optional
17
+ * creation-only `signal` (index.d.ts:98). `AgentHandle = { agent; dispose():
18
+ * Promise<void> }` (index.d.ts:155-158); `dispose()` stops the loop and
19
+ * removes the session, which is exactly the UI "stop" semantics.
20
+ * - There is NO system-prompt field on `CreateAgentOptions` (`agentOptions` is
21
+ * only provider/model/maxTokens, runtime-types.d.ts:21-28; `setup` composes
22
+ * cordis scopes and would break pure DI), so the read-only-analyst guidance
23
+ * rides the FIRST user message instead.
24
+ * - `Agent.followup(message: UserMessage): void` is a SYNCHRONOUS inbox splice
25
+ * (runtime-types.d.ts:115) — same finding as T4.4. There is no per-message
26
+ * response promise; the result must be read back from the session.
27
+ * - "Getting the analysis result": `Agent.whenIdle(): Promise<void>` resolves
28
+ * after whole-agent quiescence (runtime-types.d.ts:87), and a waking
29
+ * followup flips status to running synchronously (runtime-types.d.ts:161-172),
30
+ * so `followup → whenIdle → read log` observes the completed turn. The text
31
+ * is read from `agent.session.deriveMessages()` (dsh-session
32
+ * index.d.ts:259, the cached surface projection); token accounting rides
33
+ * `assistant/message` events as `data.usage?: TokenUsage`
34
+ * (dsh-session types.d.ts:279-285, dsh-llm types.d.ts:123-129) — that feeds
35
+ * `tokensHint`.
36
+ * - `Agent.cancel(cause: AgentCancelCause): void` aborts the active turn
37
+ * (runtime-types.d.ts:80); `{ kind: 'user' }` is the honest cause for a
38
+ * user-facing stop/timeout (dsh-session types.d.ts:118-127).
39
+ *
40
+ * Bounds (§7-B token-cost touchpoint, design risk 12): gated by live
41
+ * `analysis.enabled` (default false), input truncated to `maxInputChars`,
42
+ * first response and every follow-up bounded by `analysisTimeoutMs` (timeout
43
+ * cancels the in-flight turn), and at most `maxActiveSessions` concurrent
44
+ * analysis sessions. No periodic/automatic analysis exists here by design.
45
+ *
46
+ * Error vocabulary (contractual):
47
+ * `analysis_disabled | create_failed | timeout | too_many_active | cancelled`.
48
+ * `create_failed` covers the whole establishment phase of `request()`
49
+ * (create + priming followup + first-turn wait); `cancelled` covers follow-ups
50
+ * against a session that is unknown, already stopped, or died mid-turn.
51
+ *
52
+ * Honesty: results are AI-generated inference; every {@link AnalysisResult}
53
+ * carries {@link ANALYSIS_DISCLAIMER} for the UI to display.
54
+ *
55
+ * Log lines NEVER carry the analyzed content: no `summaryText`, no follow-up
56
+ * question, no model reply — only kind/title/analysisSessionId/outcome/token
57
+ * hints and sizes (S8).
58
+ *
59
+ * Pure DI: no cordis/dsh imports; `ctx.agents.create` is injected through the
60
+ * minimal structural face {@link AnalysisAgentFace} (method-syntax members
61
+ * keep parameter checks bivariant, so the SDK's branded `SessionId` / wider
62
+ * `UserMessage` / union `AgentCancelCause` signatures remain assignable).
63
+ *
64
+ * @module
65
+ */
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Minimal structural faces over the dsh-agent / dsh-session SDK.
69
+ // ---------------------------------------------------------------------------
70
+
71
+ /** Widened user-message face (same shape family as dsh-inject's, F11). */
72
+ export interface AnalysisUserMessageFace {
73
+ readonly id: string
74
+ readonly role: 'user'
75
+ readonly content: ReadonlyArray<{ readonly type: string; readonly text?: string }>
76
+ readonly source: { readonly kind: string; readonly plugin?: string }
77
+ }
78
+
79
+ /** Widened derived-message face over dsh-llm `Message` (message.d.ts:120-129). */
80
+ export interface AnalysisDerivedMessageFace {
81
+ readonly role: string
82
+ readonly content: ReadonlyArray<{ readonly type: string; readonly text?: string }>
83
+ }
84
+
85
+ /** Widened session-event envelope face (dsh-session types.d.ts:425-443). */
86
+ export interface AnalysisSessionEventFace {
87
+ readonly type: string
88
+ /** `assistant/message` events carry `{ usage?: TokenUsage }` here. */
89
+ readonly data?: unknown
90
+ }
91
+
92
+ /** Read face over the live session log (dsh-session index.d.ts:106-267). */
93
+ export interface AnalysisSessionLogFace {
94
+ /** Cached surface projection of the derived LLM history (index.d.ts:259). */
95
+ deriveMessages(): ReadonlyArray<AnalysisDerivedMessageFace>
96
+ /** Immutable append-only event snapshot (index.d.ts:174). */
97
+ readonly events: ReadonlyArray<AnalysisSessionEventFace>
98
+ }
99
+
100
+ /** Cancellation cause face; `{kind:'user'}` ∈ `AgentCancelCause` (dsh-session types.d.ts:118-127). */
101
+ export interface AnalysisCancelCauseFace {
102
+ readonly kind: 'user'
103
+ }
104
+
105
+ /** Live-agent face: prompt in, quiescence + log read back (runtime-types.d.ts:60-133). */
106
+ export interface AnalysisAgentDriverFace {
107
+ readonly session: AnalysisSessionLogFace
108
+ followup(message: AnalysisUserMessageFace): void
109
+ cancel(cause: AnalysisCancelCauseFace): void
110
+ whenIdle(): Promise<void>
111
+ }
112
+
113
+ /**
114
+ * The dedicated analysis session handle — `AgentHandle` face
115
+ * (index.d.ts:155-158): send messages via `agent.followup`, read responses via
116
+ * `agent.whenIdle` + `agent.session`, stop via `dispose()`.
117
+ */
118
+ export interface AnalysisSession {
119
+ readonly agent: AnalysisAgentDriverFace
120
+ dispose(): Promise<void>
121
+ }
122
+
123
+ /** Per-agent options face over SDK `AgentOptions` (runtime-types.d.ts:21-28). */
124
+ export interface AnalysisAgentOptionsFace {
125
+ /** Provider route (must have a registered adapter at call time). */
126
+ readonly provider?: string
127
+ /** Model id interpreted by the selected provider adapter. */
128
+ readonly model?: string
129
+ /** Maximum output tokens for each conversation-model request. */
130
+ readonly maxTokens?: number
131
+ }
132
+
133
+ /** `CreateAgentOptions` face (index.d.ts:65-118): engine-minted id + creation abort. */
134
+ export interface AnalysisCreateOptions {
135
+ readonly sessionId: string
136
+ readonly signal?: AbortSignal
137
+ /**
138
+ * Provider/model routing for the analysis agent (`CreateAgentOptions.
139
+ * agentOptions`). The engine never sets this — the integration layer's
140
+ * create adapter resolves and attaches it (A-1: an agent created without
141
+ * provider/model fails prompt assembly on the `{{model}}` variable and
142
+ * `buildRequest`, completing with an empty summary and zero tokens).
143
+ */
144
+ readonly agentOptions?: AnalysisAgentOptionsFace
145
+ /**
146
+ * Session creation metadata (`CreateAgentOptions.meta` subset). Also
147
+ * attached by the create adapter, never the engine: the deployment
148
+ * persona's `{{cwd}}` prompt variable reads `session.header.cwd`, which
149
+ * only `meta.cwd` populates (same reason dsh-headless passes
150
+ * `meta: { cwd: process.cwd() }` on its own create call).
151
+ */
152
+ readonly meta?: { readonly cwd?: string }
153
+ }
154
+
155
+ /** Minimal `ctx.agents` face: the one factory entry point this engine uses. */
156
+ export interface AnalysisAgentFace {
157
+ /** `AgentRegistry.create` (index.d.ts:288). */
158
+ create(options: AnalysisCreateOptions): Promise<AnalysisSession>
159
+ }
160
+
161
+ // ---------------------------------------------------------------------------
162
+ // Engine vocabulary.
163
+ // ---------------------------------------------------------------------------
164
+
165
+ /** Structured analysis input, pre-assembled by the integration layer. */
166
+ export interface AnalysisInput {
167
+ kind: 'session' | 'project' | 'cross-agent'
168
+ title: string
169
+ /** Bounded text already summarized from timelines/overviews by the caller. */
170
+ summaryText: string
171
+ meta?: Record<string, unknown>
172
+ }
173
+
174
+ export type AnalysisErrorCode =
175
+ | 'analysis_disabled'
176
+ | 'create_failed'
177
+ | 'timeout'
178
+ | 'too_many_active'
179
+ | 'cancelled'
180
+
181
+ export type AnalysisOutcome = 'completed' | 'timeout' | 'failed'
182
+
183
+ export interface AnalysisResult {
184
+ outcome: AnalysisOutcome
185
+ /**
186
+ * Present while the analysis session is still alive (completed requests,
187
+ * completed/timed-out follow-ups); absent when nothing remains to cancel
188
+ * (pre-create failures and request timeouts, where the session is disposed).
189
+ */
190
+ analysisSessionId?: string
191
+ /** Assistant text produced by this turn (only on `completed`). */
192
+ summary?: string
193
+ /** Whether the input text of THIS call was truncated to `maxInputChars`. */
194
+ truncated: boolean
195
+ /** Sum of input+output tokens reported for this turn, when the adapter reported any. */
196
+ tokensHint?: number
197
+ errorCode?: AnalysisErrorCode
198
+ /** Diagnostic error text (never analyzed-session content). */
199
+ detail?: string
200
+ /** Honesty banner for the UI; always {@link ANALYSIS_DISCLAIMER}. */
201
+ disclaimer: string
202
+ }
203
+
204
+ /** Body-free structured log entry (S8): never carries analyzed content. */
205
+ export interface AnalysisLogEntry {
206
+ op: 'create' | 'followup' | 'cancel' | 'result'
207
+ analysisSessionId?: string
208
+ kind?: AnalysisInput['kind']
209
+ title?: string
210
+ phase?: 'request' | 'followup'
211
+ outcome?: AnalysisOutcome
212
+ errorCode?: AnalysisErrorCode
213
+ truncated?: boolean
214
+ /** Size of the bounded input actually sent (chars), never its content. */
215
+ inputChars?: number
216
+ tokensHint?: number
217
+ /** cancel(): whether the id named a live analysis session. */
218
+ found?: boolean
219
+ elapsedMs?: number
220
+ detail?: string
221
+ }
222
+
223
+ // ---------------------------------------------------------------------------
224
+ // Tunables and fixed strings.
225
+ // ---------------------------------------------------------------------------
226
+
227
+ /** Input bound: chars of summary/question text fed to one turn. */
228
+ export const DEFAULT_MAX_INPUT_CHARS = 8000
229
+ /** Bound on create + first response, and on each follow-up response. */
230
+ export const DEFAULT_ANALYSIS_TIMEOUT_MS = 60_000
231
+ /** Concurrent dedicated analysis sessions tracked by one engine. */
232
+ export const DEFAULT_MAX_ACTIVE_ANALYSES = 4
233
+ /** `source.plugin` attribution and analysis session id prefix. */
234
+ export const DEFAULT_PLUGIN_NAME = 'agent-sidecar'
235
+
236
+ /** Title chars kept in prompts and logs (titles are untrusted input too). */
237
+ const MAX_TITLE_CHARS = 200
238
+
239
+ /** Appended to the input text when it was cut at `maxInputChars`. */
240
+ export const TRUNCATION_MARKER = '\n…[输入已截断 / input truncated]'
241
+
242
+ /** Honesty banner attached to every result (design §7-B / risk 12). */
243
+ export const ANALYSIS_DISCLAIMER =
244
+ 'AI 分析仅供参考,由模型基于有界摘要推断生成,可能不完整或有误 / AI-generated analysis for reference only; inferred from a bounded summary and may be incomplete or wrong.'
245
+
246
+ /**
247
+ * Read-only-analyst guidance. `CreateAgentOptions` has no system-prompt field
248
+ * (d.ts fact above), so this rides the first user message.
249
+ */
250
+ export const ANALYSIS_GUIDANCE = [
251
+ '你是只读分析助手:基于下面提供的 agent 会话摘要给出洞察(状态判断、异常与风险、可能的下一步建议)。',
252
+ '不执行任何操作、不调用任何工具、不修改任何东西;不要假设摘要之外的事实,摘要可能不完整或被截断,不确定处请如实说明。',
253
+ 'You are a read-only analysis assistant: provide insights (state assessment, anomalies/risks, possible next steps) based solely on the agent-session summary below.',
254
+ 'Take no actions, call no tools, change nothing; do not assume facts beyond the summary — it may be incomplete or truncated, so state uncertainty honestly.',
255
+ ].join('\n')
256
+
257
+ // ---------------------------------------------------------------------------
258
+ // Internals.
259
+ // ---------------------------------------------------------------------------
260
+
261
+ interface ActiveAnalysis {
262
+ analysisSessionId: string
263
+ kind: AnalysisInput['kind']
264
+ title: string
265
+ /** Undefined while `ctx.agents.create` is still pending (slot reservation). */
266
+ handle?: AnalysisSession
267
+ /** One turn at a time per analysis session. */
268
+ busy: boolean
269
+ /** deriveMessages() length already consumed by earlier turns. */
270
+ messageBaseline: number
271
+ /** session.events length already consumed by earlier turns. */
272
+ eventBaseline: number
273
+ /** Per-session message id counter. */
274
+ messageSeq: number
275
+ }
276
+
277
+ function describeError(error: unknown): string {
278
+ return error instanceof Error ? error.message : String(error)
279
+ }
280
+
281
+ function boundText(
282
+ text: string,
283
+ maxChars: number,
284
+ ): { text: string; truncated: boolean } {
285
+ if (text.length <= maxChars) return { text, truncated: false }
286
+ return { text: text.slice(0, maxChars) + TRUNCATION_MARKER, truncated: true }
287
+ }
288
+
289
+ function boundTitle(title: string): string {
290
+ return title.length <= MAX_TITLE_CHARS ? title : title.slice(0, MAX_TITLE_CHARS) + '…'
291
+ }
292
+
293
+ type TimeoutRace<T> = { timedOut: false; value: T } | { timedOut: true }
294
+
295
+ /** Race a promise against a bounded timer; the timer is always cleared. */
296
+ async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<TimeoutRace<T>> {
297
+ let timer: ReturnType<typeof setTimeout> | undefined
298
+ try {
299
+ return await Promise.race([
300
+ promise.then((value) => ({ timedOut: false as const, value })),
301
+ new Promise<{ timedOut: true }>((resolve) => {
302
+ timer = setTimeout(() => resolve({ timedOut: true }), Math.max(1, ms))
303
+ }),
304
+ ])
305
+ } finally {
306
+ if (timer !== undefined) clearTimeout(timer)
307
+ }
308
+ }
309
+
310
+ /** Join the text blocks of assistant messages appended after `baseline`. */
311
+ function extractNewAssistantText(
312
+ messages: ReadonlyArray<AnalysisDerivedMessageFace>,
313
+ baseline: number,
314
+ ): string {
315
+ const parts: string[] = []
316
+ for (const message of messages.slice(baseline)) {
317
+ if (message.role !== 'assistant') continue
318
+ const text = message.content
319
+ .filter((block) => block.type === 'text' && typeof block.text === 'string')
320
+ .map((block) => block.text as string)
321
+ .join('\n')
322
+ if (text.length > 0) parts.push(text)
323
+ }
324
+ return parts.join('\n\n')
325
+ }
326
+
327
+ /** Sum reported input+output tokens on new `assistant/message` events. */
328
+ function extractTokensHint(
329
+ events: ReadonlyArray<AnalysisSessionEventFace>,
330
+ baseline: number,
331
+ ): number | undefined {
332
+ let total = 0
333
+ let reported = false
334
+ for (const event of events.slice(baseline)) {
335
+ if (event.type !== 'assistant/message') continue
336
+ const usage = (event.data as { usage?: { inputTokens?: unknown; outputTokens?: unknown } } | undefined)
337
+ ?.usage
338
+ if (usage === undefined) continue
339
+ reported = true
340
+ if (typeof usage.inputTokens === 'number') total += usage.inputTokens
341
+ if (typeof usage.outputTokens === 'number') total += usage.outputTokens
342
+ }
343
+ return reported ? total : undefined
344
+ }
345
+
346
+ // ---------------------------------------------------------------------------
347
+ // Engine.
348
+ // ---------------------------------------------------------------------------
349
+
350
+ export interface AnalysisEngineDeps {
351
+ /** `ctx.agents.create` face (injected by the integration layer). */
352
+ createAgent: AnalysisAgentFace['create']
353
+ /** Live `analysis.enabled` gate, re-read on every call. */
354
+ allowAnalysis(): boolean
355
+ /** Structured body-free log sink. */
356
+ log(entry: AnalysisLogEntry): void
357
+ now?: () => number
358
+ /** Input bound per turn; default {@link DEFAULT_MAX_INPUT_CHARS}. */
359
+ maxInputChars?: number
360
+ /** Per-turn wait bound; default {@link DEFAULT_ANALYSIS_TIMEOUT_MS}. */
361
+ analysisTimeoutMs?: number
362
+ /** Concurrent session cap; default {@link DEFAULT_MAX_ACTIVE_ANALYSES}. */
363
+ maxActiveSessions?: number
364
+ /** Attribution + session id prefix; default {@link DEFAULT_PLUGIN_NAME}. */
365
+ pluginName?: string
366
+ }
367
+
368
+ export class AnalysisEngine {
369
+ private readonly deps: AnalysisEngineDeps
370
+ private readonly now: () => number
371
+ private readonly maxInputChars: number
372
+ private readonly analysisTimeoutMs: number
373
+ private readonly maxActiveSessions: number
374
+ private readonly pluginName: string
375
+ private readonly active = new Map<string, ActiveAnalysis>()
376
+ private mintCounter = 0
377
+
378
+ constructor(deps: AnalysisEngineDeps) {
379
+ this.deps = deps
380
+ this.now = deps.now ?? Date.now
381
+ this.maxInputChars = deps.maxInputChars ?? DEFAULT_MAX_INPUT_CHARS
382
+ this.analysisTimeoutMs = deps.analysisTimeoutMs ?? DEFAULT_ANALYSIS_TIMEOUT_MS
383
+ this.maxActiveSessions = deps.maxActiveSessions ?? DEFAULT_MAX_ACTIVE_ANALYSES
384
+ this.pluginName = deps.pluginName ?? DEFAULT_PLUGIN_NAME
385
+ }
386
+
387
+ /** Number of live (or being-created) analysis sessions. */
388
+ get activeCount(): number {
389
+ return this.active.size
390
+ }
391
+
392
+ /**
393
+ * Start a dedicated analysis session and return its first insight.
394
+ * Establishment (create + priming prompt + first response) shares one
395
+ * `analysisTimeoutMs` budget; on timeout the turn is cancelled and the
396
+ * session disposed, so a timed-out request leaves nothing running.
397
+ */
398
+ async request(input: AnalysisInput): Promise<AnalysisResult> {
399
+ const startedAt = this.now()
400
+ const title = boundTitle(input.title)
401
+
402
+ if (!this.deps.allowAnalysis()) {
403
+ return this.failResult('request', { kind: input.kind, title }, 'analysis_disabled', {
404
+ truncated: false,
405
+ startedAt,
406
+ })
407
+ }
408
+ if (this.active.size >= this.maxActiveSessions) {
409
+ return this.failResult('request', { kind: input.kind, title }, 'too_many_active', {
410
+ truncated: false,
411
+ startedAt,
412
+ detail: `active analyses at cap (${this.maxActiveSessions})`,
413
+ })
414
+ }
415
+
416
+ const bounded = boundText(input.summaryText, this.maxInputChars)
417
+ const analysisSessionId = this.mintSessionId()
418
+ const entry: ActiveAnalysis = {
419
+ analysisSessionId,
420
+ kind: input.kind,
421
+ title,
422
+ busy: true,
423
+ messageBaseline: 0,
424
+ eventBaseline: 0,
425
+ messageSeq: 0,
426
+ }
427
+ // Reserve the slot before awaiting create so concurrent requests cannot
428
+ // overshoot the cap.
429
+ this.active.set(analysisSessionId, entry)
430
+
431
+ const deadline = startedAt + this.analysisTimeoutMs
432
+ const controller = new AbortController()
433
+ let createTimedOut = false
434
+ const createPromise = this.deps.createAgent({
435
+ sessionId: analysisSessionId,
436
+ signal: controller.signal,
437
+ })
438
+ // A late settle after timeout must neither leak a running agent nor
439
+ // surface an unhandled rejection.
440
+ void createPromise.then(
441
+ (late) => {
442
+ if (createTimedOut) void late.dispose().catch(() => {})
443
+ },
444
+ () => {},
445
+ )
446
+ let handle: AnalysisSession
447
+ try {
448
+ const created = await withTimeout(createPromise, deadline - this.now())
449
+ if (created.timedOut) {
450
+ createTimedOut = true
451
+ this.active.delete(analysisSessionId)
452
+ controller.abort()
453
+ return this.timeoutResult('request', entry, bounded.truncated, startedAt, undefined)
454
+ }
455
+ handle = created.value
456
+ } catch (error) {
457
+ this.active.delete(analysisSessionId)
458
+ return this.failResult('request', entry, 'create_failed', {
459
+ truncated: bounded.truncated,
460
+ startedAt,
461
+ detail: describeError(error),
462
+ })
463
+ }
464
+ entry.handle = handle
465
+
466
+ this.deps.log({
467
+ op: 'create',
468
+ analysisSessionId,
469
+ kind: input.kind,
470
+ title,
471
+ truncated: bounded.truncated,
472
+ inputChars: bounded.text.length,
473
+ })
474
+
475
+ const prompt = this.buildInitialPrompt(input.kind, title, bounded)
476
+ const turn = await this.runTurn(entry, prompt, deadline)
477
+ entry.busy = false
478
+
479
+ if (turn.status === 'threw') {
480
+ // The session never produced a first insight: fold into create_failed
481
+ // and clean up (nothing valuable to keep).
482
+ this.active.delete(analysisSessionId)
483
+ await this.disposeQuietly(entry)
484
+ return this.failResult('request', entry, 'create_failed', {
485
+ truncated: bounded.truncated,
486
+ startedAt,
487
+ detail: turn.detail,
488
+ })
489
+ }
490
+ if (turn.status === 'timeout') {
491
+ // Bound token burn: cancel the in-flight turn AND dispose the session —
492
+ // a request that never answered holds no reusable context.
493
+ this.cancelQuietly(entry)
494
+ this.active.delete(analysisSessionId)
495
+ await this.disposeQuietly(entry)
496
+ return this.timeoutResult('request', entry, bounded.truncated, startedAt, undefined)
497
+ }
498
+
499
+ const result: AnalysisResult = {
500
+ outcome: 'completed',
501
+ analysisSessionId,
502
+ summary: turn.summary,
503
+ truncated: bounded.truncated,
504
+ ...(turn.tokensHint !== undefined ? { tokensHint: turn.tokensHint } : {}),
505
+ disclaimer: ANALYSIS_DISCLAIMER,
506
+ }
507
+ this.logResult('request', entry, result, startedAt)
508
+ return result
509
+ }
510
+
511
+ /**
512
+ * Ask an incremental follow-up question on an established analysis session.
513
+ * A timeout cancels the in-flight turn but KEEPS the session (its prior
514
+ * context stays valuable; the UI may retry or cancel).
515
+ */
516
+ async followup(analysisSessionId: string, question: string): Promise<AnalysisResult> {
517
+ const startedAt = this.now()
518
+ const entry = this.active.get(analysisSessionId)
519
+
520
+ if (!this.deps.allowAnalysis()) {
521
+ return this.failResult('followup', entry ?? { analysisSessionId }, 'analysis_disabled', {
522
+ truncated: false,
523
+ startedAt,
524
+ })
525
+ }
526
+ if (entry === undefined || entry.handle === undefined) {
527
+ return this.failResult('followup', { analysisSessionId }, 'cancelled', {
528
+ truncated: false,
529
+ startedAt,
530
+ detail: 'unknown or already-cancelled analysis session',
531
+ })
532
+ }
533
+ if (entry.busy) {
534
+ return this.failResult('followup', entry, 'too_many_active', {
535
+ truncated: false,
536
+ startedAt,
537
+ detail: 'a turn is already in flight on this analysis session',
538
+ })
539
+ }
540
+
541
+ const bounded = boundText(question, this.maxInputChars)
542
+ this.deps.log({
543
+ op: 'followup',
544
+ analysisSessionId,
545
+ kind: entry.kind,
546
+ title: entry.title,
547
+ truncated: bounded.truncated,
548
+ inputChars: bounded.text.length,
549
+ })
550
+
551
+ entry.busy = true
552
+ try {
553
+ const turn = await this.runTurn(entry, bounded.text, startedAt + this.analysisTimeoutMs)
554
+ if (turn.status === 'threw') {
555
+ // The live agent rejected the splice/wait — it is gone underneath us.
556
+ this.active.delete(analysisSessionId)
557
+ await this.disposeQuietly(entry)
558
+ return this.failResult('followup', entry, 'cancelled', {
559
+ truncated: bounded.truncated,
560
+ startedAt,
561
+ detail: turn.detail,
562
+ })
563
+ }
564
+ if (turn.status === 'timeout') {
565
+ this.cancelQuietly(entry)
566
+ return this.timeoutResult('followup', entry, bounded.truncated, startedAt, analysisSessionId)
567
+ }
568
+ const result: AnalysisResult = {
569
+ outcome: 'completed',
570
+ analysisSessionId,
571
+ summary: turn.summary,
572
+ truncated: bounded.truncated,
573
+ ...(turn.tokensHint !== undefined ? { tokensHint: turn.tokensHint } : {}),
574
+ disclaimer: ANALYSIS_DISCLAIMER,
575
+ }
576
+ this.logResult('followup', entry, result, startedAt)
577
+ return result
578
+ } finally {
579
+ entry.busy = false
580
+ }
581
+ }
582
+
583
+ /**
584
+ * Stop and dispose one analysis session (UI stop button). Idempotent: an
585
+ * unknown id resolves as a logged no-op.
586
+ */
587
+ async cancel(analysisSessionId: string): Promise<void> {
588
+ const entry = this.active.get(analysisSessionId)
589
+ if (entry === undefined) {
590
+ this.deps.log({ op: 'cancel', analysisSessionId, found: false })
591
+ return
592
+ }
593
+ this.active.delete(analysisSessionId)
594
+ this.cancelQuietly(entry)
595
+ await this.disposeQuietly(entry)
596
+ this.deps.log({
597
+ op: 'cancel',
598
+ analysisSessionId,
599
+ kind: entry.kind,
600
+ title: entry.title,
601
+ found: true,
602
+ })
603
+ }
604
+
605
+ // -------------------------------------------------------------------------
606
+ // Turn driving.
607
+ // -------------------------------------------------------------------------
608
+
609
+ private async runTurn(
610
+ entry: ActiveAnalysis,
611
+ text: string,
612
+ deadline: number,
613
+ ): Promise<
614
+ | { status: 'completed'; summary: string; tokensHint?: number }
615
+ | { status: 'timeout' }
616
+ | { status: 'threw'; detail: string }
617
+ > {
618
+ const handle = entry.handle!
619
+ const session = handle.agent.session
620
+ entry.messageBaseline = session.deriveMessages().length
621
+ entry.eventBaseline = session.events.length
622
+
623
+ const message: AnalysisUserMessageFace = {
624
+ id: `${entry.analysisSessionId}-msg-${++entry.messageSeq}`,
625
+ role: 'user',
626
+ content: [{ type: 'text', text }],
627
+ source: { kind: 'plugin', plugin: this.pluginName },
628
+ }
629
+
630
+ try {
631
+ // Synchronous inbox splice; waking delivery flips status synchronously,
632
+ // so the subsequent whenIdle() observes this turn (d.ts facts above).
633
+ handle.agent.followup(message)
634
+ } catch (error) {
635
+ return { status: 'threw', detail: describeError(error) }
636
+ }
637
+
638
+ let idle: TimeoutRace<void>
639
+ try {
640
+ idle = await withTimeout(handle.agent.whenIdle(), deadline - this.now())
641
+ } catch (error) {
642
+ return { status: 'threw', detail: describeError(error) }
643
+ }
644
+ if (idle.timedOut) return { status: 'timeout' }
645
+
646
+ const summary = extractNewAssistantText(session.deriveMessages(), entry.messageBaseline)
647
+ const tokensHint = extractTokensHint(session.events, entry.eventBaseline)
648
+ entry.messageBaseline = session.deriveMessages().length
649
+ entry.eventBaseline = session.events.length
650
+ return {
651
+ status: 'completed',
652
+ summary,
653
+ ...(tokensHint !== undefined ? { tokensHint } : {}),
654
+ }
655
+ }
656
+
657
+ private buildInitialPrompt(
658
+ kind: AnalysisInput['kind'],
659
+ title: string,
660
+ bounded: { text: string; truncated: boolean },
661
+ ): string {
662
+ return [
663
+ ANALYSIS_GUIDANCE,
664
+ '',
665
+ `[分析对象 / subject] kind=${kind} title=${title}`,
666
+ '',
667
+ `--- 会话摘要开始 / summary begin (有界输入${bounded.truncated ? ',已截断 / truncated' : ''}) ---`,
668
+ bounded.text,
669
+ '--- 会话摘要结束 / summary end ---',
670
+ ].join('\n')
671
+ }
672
+
673
+ // -------------------------------------------------------------------------
674
+ // Cleanup and result/log helpers.
675
+ // -------------------------------------------------------------------------
676
+
677
+ private cancelQuietly(entry: ActiveAnalysis): void {
678
+ try {
679
+ entry.handle?.agent.cancel({ kind: 'user' })
680
+ } catch (error) {
681
+ this.deps.log({
682
+ op: 'cancel',
683
+ analysisSessionId: entry.analysisSessionId,
684
+ found: true,
685
+ detail: `cancel threw: ${describeError(error)}`,
686
+ })
687
+ }
688
+ }
689
+
690
+ private async disposeQuietly(entry: ActiveAnalysis): Promise<void> {
691
+ if (entry.handle === undefined) return
692
+ try {
693
+ await entry.handle.dispose()
694
+ } catch (error) {
695
+ this.deps.log({
696
+ op: 'cancel',
697
+ analysisSessionId: entry.analysisSessionId,
698
+ found: true,
699
+ detail: `dispose threw: ${describeError(error)}`,
700
+ })
701
+ }
702
+ }
703
+
704
+ private mintSessionId(): string {
705
+ return `${this.pluginName}-analysis-${this.now().toString(36)}-${++this.mintCounter}`
706
+ }
707
+
708
+ private failResult(
709
+ phase: 'request' | 'followup',
710
+ ident: Partial<Pick<ActiveAnalysis, 'analysisSessionId' | 'kind' | 'title'>>,
711
+ errorCode: AnalysisErrorCode,
712
+ opts: { truncated: boolean; startedAt: number; detail?: string },
713
+ ): AnalysisResult {
714
+ const result: AnalysisResult = {
715
+ outcome: 'failed',
716
+ truncated: opts.truncated,
717
+ errorCode,
718
+ ...(opts.detail !== undefined ? { detail: opts.detail } : {}),
719
+ disclaimer: ANALYSIS_DISCLAIMER,
720
+ }
721
+ this.deps.log({
722
+ op: 'result',
723
+ phase,
724
+ ...(ident.analysisSessionId !== undefined
725
+ ? { analysisSessionId: ident.analysisSessionId }
726
+ : {}),
727
+ ...(ident.kind !== undefined ? { kind: ident.kind } : {}),
728
+ ...(ident.title !== undefined ? { title: ident.title } : {}),
729
+ outcome: 'failed',
730
+ errorCode,
731
+ ...(opts.detail !== undefined ? { detail: opts.detail } : {}),
732
+ elapsedMs: this.now() - opts.startedAt,
733
+ })
734
+ return result
735
+ }
736
+
737
+ private timeoutResult(
738
+ phase: 'request' | 'followup',
739
+ entry: ActiveAnalysis,
740
+ truncated: boolean,
741
+ startedAt: number,
742
+ analysisSessionId: string | undefined,
743
+ ): AnalysisResult {
744
+ const result: AnalysisResult = {
745
+ outcome: 'timeout',
746
+ ...(analysisSessionId !== undefined ? { analysisSessionId } : {}),
747
+ truncated,
748
+ errorCode: 'timeout',
749
+ disclaimer: ANALYSIS_DISCLAIMER,
750
+ }
751
+ this.deps.log({
752
+ op: 'result',
753
+ phase,
754
+ analysisSessionId: entry.analysisSessionId,
755
+ kind: entry.kind,
756
+ title: entry.title,
757
+ outcome: 'timeout',
758
+ errorCode: 'timeout',
759
+ elapsedMs: this.now() - startedAt,
760
+ })
761
+ return result
762
+ }
763
+
764
+ private logResult(
765
+ phase: 'request' | 'followup',
766
+ entry: ActiveAnalysis,
767
+ result: AnalysisResult,
768
+ startedAt: number,
769
+ ): void {
770
+ this.deps.log({
771
+ op: 'result',
772
+ phase,
773
+ analysisSessionId: entry.analysisSessionId,
774
+ kind: entry.kind,
775
+ title: entry.title,
776
+ outcome: result.outcome,
777
+ ...(result.tokensHint !== undefined ? { tokensHint: result.tokensHint } : {}),
778
+ truncated: result.truncated,
779
+ elapsedMs: this.now() - startedAt,
780
+ })
781
+ }
782
+ }