dsh-side-chat-plus 0.3.1

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 (44) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +317 -0
  3. package/README.zh.md +261 -0
  4. package/cordis.patch.yml +8 -0
  5. package/dsh.plugin.json +16 -0
  6. package/lib/client-registry.js +2949 -0
  7. package/lib/client-registry.js.map +1 -0
  8. package/lib/client.js +2949 -0
  9. package/lib/client.js.map +1 -0
  10. package/lib/index.js +840 -0
  11. package/lib/types/client/api.d.ts +218 -0
  12. package/lib/types/client/attachments/AttachmentRail.d.ts +39 -0
  13. package/lib/types/client/attachments/DropOverlay.d.ts +18 -0
  14. package/lib/types/client/attachments/ImageLightbox.d.ts +20 -0
  15. package/lib/types/client/attachments/MessageImage.d.ts +38 -0
  16. package/lib/types/client/attachments/index.d.ts +18 -0
  17. package/lib/types/client/index.d.ts +6 -0
  18. package/lib/types/client/locales.d.ts +178 -0
  19. package/lib/types/context-types.d.ts +390 -0
  20. package/lib/types/index.d.ts +7 -0
  21. package/lib/types/settings-shared.d.ts +24 -0
  22. package/lib/types/trust-fence.d.ts +20 -0
  23. package/lib/types/wire.d.ts +25 -0
  24. package/package.json +114 -0
  25. package/src/client/api.ts +112 -0
  26. package/src/client/attachments/AttachmentRail.module.css +89 -0
  27. package/src/client/attachments/AttachmentRail.tsx +173 -0
  28. package/src/client/attachments/DropOverlay.module.css +38 -0
  29. package/src/client/attachments/DropOverlay.tsx +62 -0
  30. package/src/client/attachments/ImageLightbox.module.css +44 -0
  31. package/src/client/attachments/ImageLightbox.tsx +58 -0
  32. package/src/client/attachments/MessageImage.module.css +61 -0
  33. package/src/client/attachments/MessageImage.tsx +120 -0
  34. package/src/client/attachments/index.ts +19 -0
  35. package/src/client/client.module.css +1032 -0
  36. package/src/client/index.tsx +1966 -0
  37. package/src/client/layout.css +16 -0
  38. package/src/client/locales.ts +181 -0
  39. package/src/context-types.ts +384 -0
  40. package/src/css-modules.d.ts +10 -0
  41. package/src/index.ts +840 -0
  42. package/src/settings-shared.ts +33 -0
  43. package/src/trust-fence.ts +70 -0
  44. package/src/wire.ts +81 -0
package/src/index.ts ADDED
@@ -0,0 +1,840 @@
1
+ /**
2
+ * dsh-side-chat host half: the /sidechat JSON API. Every side chat is an
3
+ * ORDINARY session (no `origin: 'subagent'`) whose `meta.parentSession` points
4
+ * at the conversation that launched it, archived immediately so it appears in
5
+ * neither the main session list nor the subagent catalog, and driven directly
6
+ * through the live agent (followup). Model / reasoning-effort / permission are
7
+ * inherited from the launching conversation at creation and adjustable later.
8
+ *
9
+ * All routes pass the same browser-trust fence as the /api gateway (loopback
10
+ * or trusted authority; cross-site markers refuse).
11
+ */
12
+ import { randomUUID } from 'node:crypto'
13
+ import { mkdir, readFile, writeFile } from 'node:fs/promises'
14
+ import { dirname } from 'node:path'
15
+ import * as agentApi from '@deepseek-ai/dsh-agent'
16
+ import { dshHomePath } from '@deepseek-ai/dsh-home-paths'
17
+ import { SettingsConflictError, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
18
+ import z from 'schemastery'
19
+ import type {
20
+ Context,
21
+ SideAgent,
22
+ SideAgentHandle,
23
+ SideImageAttachmentRef,
24
+ SideInstallModelSelection,
25
+ SideModelSelectionRef,
26
+ SideSession,
27
+ SideSessionEvent,
28
+ } from './context-types.ts'
29
+ import { SUBCHAT_PREFS_DEFAULTS, SUBCHAT_PREFS_NS, type SubchatPrefs } from './settings-shared.ts'
30
+ import { isTrustedApiRequest } from './trust-fence.ts'
31
+ import { optionalBoolean, readJsonBody, requireString, SidechatError, writeError, writeJson, writeOk } from './wire.ts'
32
+
33
+ /** Plugin identity for cordis.yml rows. */
34
+ export const name = 'dsh-side-chat-plus'
35
+
36
+ /** Services required before mounting. */
37
+ export const inject = [
38
+ 'webServer',
39
+ 'sessions',
40
+ 'agents',
41
+ 'workspaceRegistry',
42
+ 'sessionQuery',
43
+ 'sandboxPolicy',
44
+ 'permissionPresets',
45
+ 'agentPresets',
46
+ 'llm',
47
+ 'attachments',
48
+ 'commands',
49
+ ]
50
+
51
+ /**
52
+ * Couple a side chat's selection to its agent so prompt assembly and request
53
+ * routing switch together. Read through the namespace object rather than a
54
+ * named import: a named import fails the ESM link on any build that drops the
55
+ * export, while this degrades to the request-only waterfall in `start`.
56
+ */
57
+ const installModelSelection = (agentApi as unknown as { installModelSelection?: SideInstallModelSelection }).installModelSelection
58
+
59
+ /** One live side chat the host owns. */
60
+ interface SidechatRecord {
61
+ childId: string
62
+ parentSessionId: string
63
+ handle: SideAgentHandle
64
+ /** Live selection coupled to the agent; mutated by `sidechat.selectModel`. */
65
+ selection: SideModelSelectionRef
66
+ createdAt: number
67
+ }
68
+
69
+ /** Durable record list file (cleanup is a later feature; the list is the record). */
70
+ const RECORD_FILE = 'dsh-side-chat-sessions.json'
71
+
72
+ /** The record-list file path under the harness home. */
73
+ function recordFilePath(): string {
74
+ return dshHomePath(RECORD_FILE)
75
+ }
76
+
77
+ /** Read the durable side-chat record list (absent/malformed → empty). */
78
+ async function readRecords(): Promise<Array<{ childId: string; parentSessionId: string; createdAt: number }>> {
79
+ try {
80
+ const raw = await readFile(recordFilePath(), 'utf8')
81
+ const parsed = JSON.parse(raw) as unknown
82
+ if (!Array.isArray(parsed)) return []
83
+ return parsed.filter((entry): entry is { childId: string; parentSessionId: string; createdAt: number } => {
84
+ const record = entry as Record<string, unknown> | null
85
+ return typeof record?.childId === 'string' && typeof record?.parentSessionId === 'string'
86
+ }).map((entry) => ({
87
+ childId: entry.childId,
88
+ parentSessionId: entry.parentSessionId,
89
+ createdAt: typeof entry.createdAt === 'number' ? entry.createdAt : 0,
90
+ }))
91
+ } catch {
92
+ return []
93
+ }
94
+ }
95
+
96
+ /** Persist the live side-chat record list (the "record" a future cleanup consumes). */
97
+ async function writeRecords(records: Array<{ childId: string; parentSessionId: string; createdAt: number }>): Promise<void> {
98
+ const path = recordFilePath()
99
+ await mkdir(dirname(path), { recursive: true })
100
+ await writeFile(path, JSON.stringify(records, null, 2), 'utf8')
101
+ }
102
+
103
+ /** Browser-submitted prompt content part (text or base64 image). */
104
+ type PromptContentPart = { type: 'text'; text: string } | { type: 'image'; mediaType: string; data: string; name?: string }
105
+
106
+ /** Model-facing content block (text or durable image reference). */
107
+ type ContentBlock = { type: 'text'; text: string } | { type: 'image'; attachment: SideImageAttachmentRef }
108
+
109
+ /** One transcript block (text or durable image reference). */
110
+ type TranscriptBlock = { type: 'text'; text: string } | { type: 'image'; ref: SideImageAttachmentRef } | { type: 'reasoning'; text: string }
111
+
112
+ /** The lookup guidance appended as an extra text block (hidden from the UI). */
113
+ const GUIDANCE_MARKER = '[sidechat-guidance]'
114
+ const LOOKUP_GUIDANCE = `${GUIDANCE_MARKER}[需要时,请读取工作区文件或查阅发起此问题的父会话记录来补充信息;若已尽力仍不足,请说明限制。]`
115
+ const NO_LOOKUP_GUIDANCE = `${GUIDANCE_MARKER}[请仅基于上述内容直接回答,不要主动查阅工作区文件或父会话记录。]`
116
+ /** Legacy (pre-marker) guidance prefixes, filtered so older messages hide too. */
117
+ const LEGACY_GUIDANCE_PREFIXES = ['[需要时,请读取工作区文件', '[请仅基于上述内容']
118
+
119
+ /** Decode one base64 string into bytes (host-side; Node Buffer). */
120
+ function decodeBase64(data: string): Uint8Array {
121
+ return new Uint8Array(Buffer.from(data, 'base64'))
122
+ }
123
+
124
+ /** A user-role message value `agent.followup` accepts (identity + content + source). */
125
+ function userMessage(content: ContentBlock[]): { id: string; role: 'user'; content: ContentBlock[]; source: { kind: 'user' } } {
126
+ return {
127
+ id: randomUUID(),
128
+ role: 'user',
129
+ content,
130
+ source: { kind: 'user' },
131
+ }
132
+ }
133
+
134
+ /** Fold a raw session event log into a minimal user/assistant transcript with image refs. */
135
+ function foldTranscript(events: readonly { type: string; data: Record<string, unknown> }[]): Array<{ role: 'user' | 'assistant'; blocks: TranscriptBlock[] }> {
136
+ const fold = (content: unknown): TranscriptBlock[] => {
137
+ if (!Array.isArray(content)) return []
138
+ const blocks: TranscriptBlock[] = []
139
+ for (const raw of content) {
140
+ const b = raw as Record<string, unknown> | null
141
+ if (b?.type === 'text' && typeof b.text === 'string') {
142
+ const text = b.text
143
+ const hidden = text.startsWith(GUIDANCE_MARKER) || LEGACY_GUIDANCE_PREFIXES.some((prefix) => text.startsWith(prefix))
144
+ if (!hidden) blocks.push({ type: 'text', text })
145
+ } else if (b?.type === 'image' && b.attachment !== null && typeof b.attachment === 'object') {
146
+ blocks.push({ type: 'image', ref: b.attachment as SideImageAttachmentRef })
147
+ } else if (b?.type === 'reasoning' && typeof b.text === 'string') {
148
+ blocks.push({ type: 'reasoning', text: b.text })
149
+ }
150
+ }
151
+ return blocks
152
+ }
153
+ const messages: Array<{ role: 'user' | 'assistant'; blocks: TranscriptBlock[] }> = []
154
+ for (const event of events) {
155
+ if (event.type === 'user/message') {
156
+ // Skip plugin-injected runtime context (system prompt contexts like the
157
+ // sandbox policy), which the loop projects as user/message events.
158
+ const source = (event.data as { source?: { kind?: string } }).source
159
+ if (source !== undefined && source.kind !== 'user') continue
160
+ const blocks = fold(event.data.content)
161
+ if (blocks.length > 0) messages.push({ role: 'user', blocks })
162
+ } else if (event.type === 'assistant/message') {
163
+ const message = event.data.message as Record<string, unknown> | null
164
+ const blocks = fold(message?.content)
165
+ if (blocks.length > 0) messages.push({ role: 'assistant', blocks })
166
+ }
167
+ }
168
+ return messages
169
+ }
170
+
171
+ /** The start time of the still-open turn, or undefined when no turn is running. */
172
+ function openTurnStart(events: readonly SideSessionEvent[]): number | undefined {
173
+ let lastStart: number | undefined
174
+ let lastEnd: number | undefined
175
+ for (const event of events) {
176
+ if (event.type === 'turn/start') lastStart = event.time
177
+ else if (event.type === 'turn/end') lastEnd = event.time
178
+ }
179
+ if (lastStart === undefined) return undefined
180
+ if (lastEnd !== undefined && lastEnd >= lastStart) return undefined
181
+ return lastStart
182
+ }
183
+
184
+ /** Schemastery schema for the user-facing preferences (validated by the settings service). */
185
+ const PrefsSchema: z<SubchatPrefs> = z.object({
186
+ lookupDefault: z.boolean().default(SUBCHAT_PREFS_DEFAULTS.lookupDefault),
187
+ sendImmediately: z.boolean().default(SUBCHAT_PREFS_DEFAULTS.sendImmediately),
188
+ defaultPrompt: z.string().default(SUBCHAT_PREFS_DEFAULTS.defaultPrompt),
189
+ bringMode: z.union(['draft', 'context']).default(SUBCHAT_PREFS_DEFAULTS.bringMode),
190
+ })
191
+
192
+ /** Live settings face (bound when the settings service is mounted). */
193
+ interface SubchatSettingsFace {
194
+ get(): { value?: unknown; revision?: number }
195
+ update(patch: Record<string, unknown>, expectedRevision?: number): Promise<{ value?: unknown; revision?: number }>
196
+ }
197
+
198
+ /** The API method table bound to the plugin context and the live side-chat map. */
199
+ function buildApi(ctx: Context, sideChats: Map<string, SidechatRecord>, getSettings: () => SubchatSettingsFace | undefined) {
200
+ /** Persist the current live records (best-effort; never blocks the API). */
201
+ const persist = (): void => {
202
+ const records = [...sideChats.values()].map((record) => ({
203
+ childId: record.childId,
204
+ parentSessionId: record.parentSessionId,
205
+ createdAt: record.createdAt,
206
+ }))
207
+ void writeRecords(records).catch((error: unknown) => {
208
+ console.warn('[dsh-side-chat] record write failed:', error instanceof Error ? error.message : String(error))
209
+ })
210
+ }
211
+
212
+ /** Narrow a payload value to a non-empty prompt content array. */
213
+ const requireContent = (payload: unknown): PromptContentPart[] => {
214
+ const record = payload as Record<string, unknown> | null
215
+ const content = record?.content
216
+ if (!Array.isArray(content) || content.length === 0) {
217
+ throw new SidechatError('bad-request', 'missing or invalid "content"')
218
+ }
219
+ const parts: PromptContentPart[] = []
220
+ for (const raw of content) {
221
+ const part = raw as Record<string, unknown> | null
222
+ if (part?.type === 'text' && typeof part.text === 'string' && part.text !== '') {
223
+ parts.push({ type: 'text', text: part.text })
224
+ } else if (part?.type === 'image' && typeof part.mediaType === 'string' && typeof part.data === 'string' && part.data !== '') {
225
+ parts.push({
226
+ type: 'image',
227
+ mediaType: part.mediaType,
228
+ data: part.data,
229
+ ...(typeof part.name === 'string' && part.name !== '' ? { name: part.name } : {}),
230
+ })
231
+ } else {
232
+ throw new SidechatError('bad-request', 'invalid content block')
233
+ }
234
+ }
235
+ if (parts.every((p) => p.type !== 'text')) {
236
+ // Content must carry at least one text block (image-only prompts unsupported).
237
+ throw new SidechatError('bad-request', 'content must include text')
238
+ }
239
+ return parts
240
+ }
241
+
242
+ /** Promote browser base64 images to durable references; append hidden guidance. */
243
+ const durableContent = async (parts: PromptContentPart[], lookupEnabled: boolean): Promise<ContentBlock[]> => {
244
+ const limits = ctx.attachments.imageLimits
245
+ const images = parts.filter((p) => p.type === 'image')
246
+ if (images.length > limits.maxImagesPerMessage) {
247
+ throw new SidechatError('too-many-images', `prompt exceeds the ${limits.maxImagesPerMessage}-image limit`, 400)
248
+ }
249
+ const blocks: ContentBlock[] = []
250
+ for (const part of parts) {
251
+ if (part.type === 'text') {
252
+ blocks.push({ type: 'text', text: part.text })
253
+ } else {
254
+ const data = decodeBase64(part.data)
255
+ await ctx.attachments.validateImage({ data, mediaType: part.mediaType, ...(part.name === undefined ? {} : { name: part.name }) })
256
+ const attachment = await ctx.attachments.saveImage({ data, mediaType: part.mediaType, ...(part.name === undefined ? {} : { name: part.name }) })
257
+ blocks.push({ type: 'image', attachment })
258
+ }
259
+ }
260
+ blocks.push({ type: 'text', text: lookupEnabled ? LOOKUP_GUIDANCE : NO_LOOKUP_GUIDANCE })
261
+ return blocks
262
+ }
263
+
264
+ /** Resolve the live launching agent (the side chat's durable parent must be live to start/continue). */
265
+ const parentOf = (parentSessionId: string): SideAgent => {
266
+ const parent = ctx.agents.get(parentSessionId)
267
+ if (parent === undefined) {
268
+ throw new SidechatError('parent-unavailable', 'the launching conversation is not live; reopen it to start or continue a side chat', 409)
269
+ }
270
+ return parent
271
+ }
272
+
273
+ /** The parent conversation's current model selection (for staged-mode display). */
274
+ const inherit = (payload: unknown): { provider: string; model: string; reasoningEffort?: string } => {
275
+ const parentSessionId = requireString(payload, 'parentSessionId')
276
+ const parent = parentOf(parentSessionId)
277
+ const parentConfig = parent.session.requestHeader?.()?.config
278
+ return {
279
+ provider: parentConfig?.provider ?? parent.options.provider ?? '',
280
+ model: parentConfig?.model ?? parent.options.model ?? '',
281
+ ...(parentConfig?.reasoningEffort === undefined ? {} : { reasoningEffort: parentConfig.reasoningEffort }),
282
+ }
283
+ }
284
+
285
+ /** Resolve the live side-chat agent behind a childId. */
286
+ const childOf = (childId: string): SideAgent => {
287
+ const record = sideChats.get(childId)
288
+ if (record !== undefined) return record.handle.agent
289
+ const live = ctx.agents.get(childId)
290
+ if (live !== undefined) return live
291
+ throw new SidechatError('child-unavailable', `side chat "${childId}" is not live`, 409)
292
+ }
293
+
294
+ /** Resolve the live side-chat session (for permission writes). */
295
+ const sessionOf = (childId: string): SideSession => {
296
+ const child = childOf(childId)
297
+ const session = ctx.sessions.get(childId) ?? child.session
298
+ if (session === undefined) throw new SidechatError('child-unavailable', `side chat "${childId}" has no session`, 409)
299
+ return session
300
+ }
301
+
302
+ /** List the host slash commands available to one side-chat agent. */
303
+ const commands = (payload: unknown): { commands: Array<{ name: string; description: string }> } => {
304
+ const childId = requireString(payload, 'childId')
305
+ const child = childOf(childId)
306
+ return { commands: ctx.commands.list(child).map((c) => ({ name: c.name, description: c.description })) }
307
+ }
308
+
309
+ /** Execute one host slash command against a side-chat agent. */
310
+ const command = async (payload: unknown): Promise<{ executed: boolean }> => {
311
+ const childId = requireString(payload, 'childId')
312
+ const line = requireString(payload, 'line')
313
+ const child = childOf(childId)
314
+ const result = await ctx.commands.execute(child, line, new AbortController().signal)
315
+ return { executed: result !== undefined }
316
+ }
317
+
318
+ /** Fold one side-chat agent's plan/goal state for its composer chrome. */
319
+ const state = (payload: unknown): { plan: { active: boolean; pending: boolean }; goal: { id: string; objective: string } | null } => {
320
+ const childId = requireString(payload, 'childId')
321
+ const child = childOf(childId)
322
+ const events = child.session.events ?? []
323
+ // Plan fold mirrors dsh-plan-mode's `plan` projection.
324
+ let planActive = false
325
+ let planWanted: boolean | null = null
326
+ // Goal fold mirrors dsh-goal's last-wins `goal/change` projection.
327
+ let goal: { id: string; objective: string } | null = null
328
+ for (const event of events) {
329
+ const data = event.data as Record<string, unknown>
330
+ if (event.type === 'command/run' && data.name === 'plan') {
331
+ if (data.args === undefined) continue
332
+ const wanted = String(data.args).trim() !== 'off'
333
+ if (wanted !== planWanted) planWanted = wanted
334
+ } else if (event.type === 'plan/mode') {
335
+ planActive = data.active === true
336
+ planWanted = null
337
+ } else if (event.type === 'goal/change') {
338
+ if (data.operation === 'clear') {
339
+ goal = null
340
+ } else if (data.goal !== null && typeof data.goal === 'object') {
341
+ const g = data.goal as Record<string, unknown>
342
+ if (typeof g.id === 'string' && typeof g.objective === 'string') {
343
+ goal = { id: g.id, objective: g.objective }
344
+ }
345
+ }
346
+ }
347
+ }
348
+ return {
349
+ plan: { active: planActive, pending: planWanted !== null && planWanted !== planActive },
350
+ goal,
351
+ }
352
+ }
353
+
354
+ /** Create one side chat: inherit, archive, record, deliver the first prompt. */
355
+ const start = async (payload: unknown): Promise<{ childId: string; provider: string; model: string; reasoningEffort?: string }> => {
356
+ const parentSessionId = requireString(payload, 'parentSessionId')
357
+ const content = requireContent(payload)
358
+ const lookupEnabled = optionalBoolean(payload, 'lookupEnabled')
359
+ const parent = parentOf(parentSessionId)
360
+
361
+ const record = payload as { provider?: unknown; model?: unknown; reasoningEffort?: unknown; preset?: unknown }
362
+ const parentConfig = parent.session.requestHeader?.()?.config
363
+ const parentProvider = parentConfig?.provider ?? parent.options.provider ?? ''
364
+ const parentModel = parentConfig?.model ?? parent.options.model ?? ''
365
+ // A client-supplied selection wins over the inherited one (lets the user
366
+ // pick a provider/model before the first send while "send immediately" is off).
367
+ const provider = typeof record.provider === 'string' && record.provider !== '' ? record.provider : parentProvider
368
+ const model = typeof record.model === 'string' && record.model !== '' ? record.model : parentModel
369
+ const maxTokens = parentConfig?.maxTokens ?? parent.options.maxTokens
370
+ const explicitEffort = typeof record.reasoningEffort === 'string' && record.reasoningEffort !== '' ? record.reasoningEffort : undefined
371
+ const reasoningEffort = explicitEffort ?? parentConfig?.reasoningEffort
372
+ const cwd = parent.session.header.cwd
373
+
374
+ // The mutable ref the agent reads live, so `sidechat.selectModel` takes
375
+ // effect on a later step instead of only after a restart.
376
+ const selection: SideModelSelectionRef = {
377
+ current: {
378
+ provider,
379
+ model,
380
+ ...(reasoningEffort === undefined ? {} : { reasoningEffort }),
381
+ },
382
+ assembled: undefined,
383
+ }
384
+ const parentCtx = parent.ctx
385
+
386
+ const childId = `subchat-${randomUUID()}`
387
+ const handle = await ctx.agents.create({
388
+ sessionId: childId,
389
+ meta: {
390
+ ...(cwd === undefined ? {} : { cwd }),
391
+ parentSession: parentSessionId,
392
+ },
393
+ agentOptions: {
394
+ provider,
395
+ model,
396
+ ...(maxTokens === undefined ? {} : { maxTokens }),
397
+ },
398
+ setup: (agentCtx: Context) => {
399
+ // Inherit the launching conversation's toolset/prompt sections.
400
+ if (parentCtx !== undefined) {
401
+ ctx.agentPresets.composeFrom(agentCtx, parentCtx)
402
+ }
403
+ // Model / reasoning-effort are adjustable at runtime. The coupled
404
+ // installer snapshots the selection before prompt assembly delegates
405
+ // and applies the same value to the request config, so a switch made
406
+ // mid-turn lands on a later step instead of assembling the prompt for
407
+ // the inherited model while calling the selected one. An absent
408
+ // effort clears the inherited one, restoring provider/default behavior.
409
+ if (installModelSelection !== undefined) {
410
+ installModelSelection(agentCtx, selection)
411
+ return
412
+ }
413
+ // Fallback for builds without that API: rewrites the request config
414
+ // only, which can split prompt assembly from routing mid-turn.
415
+ agentCtx.on('agent/request', async (_payload: unknown, next: () => Promise<Record<string, unknown>>) => {
416
+ const resolved = await next()
417
+ const sel = selection.current
418
+ if (sel === undefined) return resolved
419
+ const { reasoningEffort: _drop, ...rest } = resolved
420
+ return {
421
+ ...rest,
422
+ provider: sel.provider,
423
+ model: sel.model,
424
+ ...(sel.reasoningEffort === undefined ? {} : { reasoningEffort: sel.reasoningEffort }),
425
+ }
426
+ })
427
+ },
428
+ })
429
+
430
+ try {
431
+ // Client-supplied preset wins; otherwise inherit the launching
432
+ // conversation's permission preset (skip custom).
433
+ const explicitPreset = typeof record.preset === 'string' && record.preset !== '' ? record.preset : undefined
434
+ const parentPreset = ctx.permissionPresets.current(parent.session.events ?? [])
435
+ const preset = explicitPreset ?? parentPreset
436
+ if (preset !== 'custom') {
437
+ ctx.permissionPresets.set(handle.agent.session, preset)
438
+ }
439
+ } catch (error) {
440
+ console.warn('[dsh-side-chat] permission inherit failed:', error instanceof Error ? error.message : String(error))
441
+ }
442
+
443
+ // Archive BEFORE the first prompt so the session is hidden from every list.
444
+ await ctx.workspaceRegistry.archiveSession(childId)
445
+
446
+ sideChats.set(childId, {
447
+ childId,
448
+ parentSessionId,
449
+ handle,
450
+ selection,
451
+ createdAt: Date.now(),
452
+ })
453
+ persist()
454
+
455
+ handle.agent.followup(userMessage(await durableContent(content, lookupEnabled)))
456
+ return { childId, provider, model, ...(reasoningEffort === undefined ? {} : { reasoningEffort }) }
457
+ }
458
+
459
+ /** Deliver one later message to an existing side chat. */
460
+ const followup = async (payload: unknown): Promise<{ accepted: true }> => {
461
+ const childId = requireString(payload, 'childId')
462
+ const content = requireContent(payload)
463
+ const lookupEnabled = optionalBoolean(payload, 'lookupEnabled')
464
+ const child = childOf(childId)
465
+ child.followup(userMessage(await durableContent(content, lookupEnabled)))
466
+ return { accepted: true }
467
+ }
468
+
469
+ /** List the side chats launched by one parent conversation. */
470
+ const list = (payload: unknown): { items: Array<{ childId: string; running: boolean; runningSince?: number }> } => {
471
+ const parentSessionId = requireString(payload, 'parentSessionId')
472
+ const items: Array<{ childId: string; running: boolean; runningSince?: number }> = []
473
+ for (const record of sideChats.values()) {
474
+ if (record.parentSessionId !== parentSessionId) continue
475
+ // "Running" is decided by an open turn in the session log — the same
476
+ // signal the main conversation uses — instead of the agent status getter,
477
+ // so a turn that already settled can never leave the panel stuck on
478
+ // "thinking".
479
+ const runningSince = openTurnStart(record.handle.agent.session.events ?? [])
480
+ items.push({
481
+ childId: record.childId,
482
+ running: runningSince !== undefined,
483
+ ...(runningSince !== undefined ? { runningSince } : {}),
484
+ })
485
+ }
486
+ return { items }
487
+ }
488
+
489
+ /** Interrupt the active side chat's current turn (user-initiated stop). */
490
+ const stop = (payload: unknown): { accepted: true } => {
491
+ const childId = requireString(payload, 'childId')
492
+ const child = childOf(childId)
493
+ child.cancel({ kind: 'user' })
494
+ return { accepted: true }
495
+ }
496
+
497
+ /** Fold one side chat's transcript. */
498
+ const history = async (payload: unknown): Promise<{ messages: Array<{ role: 'user' | 'assistant'; blocks: TranscriptBlock[] }> }> => {
499
+ const childId = requireString(payload, 'childId')
500
+ const snapshot = await ctx.sessionQuery.readSession(childId)
501
+ return { messages: foldTranscript(snapshot.events) }
502
+ }
503
+
504
+ /** The deployment-resolved image policy (for client-side fast-path checks). */
505
+ const limits = (): { mediaTypes: string[]; maxImageBytes: number; maxImagesPerMessage: number; maxMessageImageBytes: number; maxImagePixels: number } => {
506
+ const l = ctx.attachments.imageLimits
507
+ return {
508
+ mediaTypes: [...l.mediaTypes],
509
+ maxImageBytes: l.maxImageBytes,
510
+ maxImagesPerMessage: l.maxImagesPerMessage,
511
+ maxMessageImageBytes: l.maxMessageImageBytes,
512
+ maxImagePixels: l.maxImagePixels,
513
+ }
514
+ }
515
+
516
+ /** Read one durable image's bytes for transcript rendering. */
517
+ const attachment = async (payload: unknown): Promise<{ mediaType: string; data: string }> => {
518
+ const childId = requireString(payload, 'childId')
519
+ const attachmentId = requireString(payload, 'attachmentId')
520
+ const snapshot = await ctx.sessionQuery.readSession(childId)
521
+ for (const message of foldTranscript(snapshot.events)) {
522
+ for (const block of message.blocks) {
523
+ if (block.type === 'image' && block.ref.attachmentId === attachmentId) {
524
+ const stored = await ctx.attachments.readImage(block.ref)
525
+ return { mediaType: stored.ref.mediaType, data: Buffer.from(stored.data).toString('base64') }
526
+ }
527
+ }
528
+ }
529
+ throw new SidechatError('not-found', `image "${attachmentId}" not found`, 404)
530
+ }
531
+
532
+ /** Adjust one side chat's model / reasoning-effort. */
533
+ const selectModel = (payload: unknown): { accepted: true } => {
534
+ const childId = requireString(payload, 'childId')
535
+ const provider = requireString(payload, 'provider')
536
+ const model = requireString(payload, 'model')
537
+ const record = sideChats.get(childId)
538
+ if (record === undefined) throw new SidechatError('child-unavailable', `side chat "${childId}" is not live`, 409)
539
+ const reasoningEffort = (payload as Record<string, unknown> | null)?.reasoningEffort
540
+ // Replace the whole object so the coupled listeners never observe a
541
+ // half-written selection (provider switched, model still inherited).
542
+ record.selection.current = {
543
+ provider,
544
+ model,
545
+ ...(typeof reasoningEffort === 'string' && reasoningEffort !== '' ? { reasoningEffort } : {}),
546
+ }
547
+ return { accepted: true }
548
+ }
549
+
550
+ /** Adjust one side chat's permission preset. */
551
+ const selectPermission = (payload: unknown): { accepted: true } => {
552
+ const childId = requireString(payload, 'childId')
553
+ const presetName = requireString(payload, 'presetName')
554
+ const session = sessionOf(childId)
555
+ ctx.permissionPresets.set(session, presetName)
556
+ return { accepted: true }
557
+ }
558
+
559
+ /** Close one side chat (tears down its agent and session). */
560
+ const dispose = async (payload: unknown): Promise<{ accepted: true }> => {
561
+ const childId = requireString(payload, 'childId')
562
+ const record = sideChats.get(childId)
563
+ if (record !== undefined) {
564
+ sideChats.delete(childId)
565
+ try {
566
+ await record.handle.dispose()
567
+ } catch (error) {
568
+ console.warn('[dsh-side-chat] dispose failed:', error instanceof Error ? error.message : String(error))
569
+ }
570
+ persist()
571
+ }
572
+ return { accepted: true }
573
+ }
574
+
575
+ /** Summarize one piece of text with the side chat's (inherited) model. */
576
+ const summarize = async (payload: unknown): Promise<{ summary: string }> => {
577
+ const parentSessionId = requireString(payload, 'parentSessionId')
578
+ const text = requireString(payload, 'text')
579
+ const record = payload as { provider?: unknown; model?: unknown; reasoningEffort?: unknown; locale?: unknown }
580
+
581
+ // Inherit the launching conversation's model unless the client supplied one.
582
+ const parent = parentOf(parentSessionId)
583
+ const parentConfig = parent.session.requestHeader?.()?.config
584
+ const provider = typeof record.provider === 'string' && record.provider !== '' ? record.provider : (parentConfig?.provider ?? parent.options.provider ?? '')
585
+ const model = typeof record.model === 'string' && record.model !== '' ? record.model : (parentConfig?.model ?? parent.options.model ?? '')
586
+ if (provider === '' || model === '') {
587
+ throw new SidechatError('bad-request', 'no model available for summarization')
588
+ }
589
+ const reasoningEffort = typeof record.reasoningEffort === 'string' && record.reasoningEffort !== '' ? record.reasoningEffort : parentConfig?.reasoningEffort
590
+
591
+ const locale = typeof record.locale === 'string' && record.locale === 'en' ? 'en' : 'zh'
592
+ const prompt = locale === 'en'
593
+ ? `Summarize the following content concisely. Keep the key points and output only the summary:\n\n${text}`
594
+ : `请对以下内容做简明扼要的摘要,保留关键信息,只输出摘要本身:\n\n${text}`
595
+
596
+ const chunks = ctx.llm.stream({
597
+ provider,
598
+ model,
599
+ ...(reasoningEffort === undefined ? {} : { reasoningEffort }),
600
+ maxTokens: 1024,
601
+ messages: [{
602
+ id: randomUUID(),
603
+ role: 'user',
604
+ content: [{ type: 'text', text: prompt }],
605
+ source: { kind: 'user' },
606
+ }],
607
+ })
608
+
609
+ let summary = ''
610
+ for await (const chunk of chunks) {
611
+ if (chunk.type === 'text-delta' && typeof chunk.text === 'string') {
612
+ summary += chunk.text
613
+ }
614
+ }
615
+ const trimmed = summary.trim()
616
+ if (trimmed === '') {
617
+ throw new SidechatError('summarize-empty', 'the model returned no summary', 502)
618
+ }
619
+ return { summary: trimmed }
620
+ }
621
+
622
+ /** Inject one piece of text into the main conversation as a collapsed context row. */
623
+ const inject = (payload: unknown): { accepted: true } => {
624
+ const parentSessionId = requireString(payload, 'parentSessionId')
625
+ const text = requireString(payload, 'text')
626
+ const record = payload as { summary?: unknown }
627
+ const summary = typeof record.summary === 'string' && record.summary.trim() !== '' ? record.summary.trim() : '从侧边聊天带回'
628
+ const parent = parentOf(parentSessionId)
629
+ parent.inject({
630
+ id: randomUUID(),
631
+ role: 'user',
632
+ content: [{ type: 'text', text }],
633
+ source: { kind: 'plugin', plugin: 'dsh-side-chat', form: 'notice', summary },
634
+ })
635
+ return { accepted: true }
636
+ }
637
+
638
+ /** Full model directory: provider groups → models → reasoning efforts. */
639
+ const directory = async (): Promise<{
640
+ groups: Array<{
641
+ id: string
642
+ name: string
643
+ models: Array<{
644
+ id: string
645
+ name: string
646
+ description?: string
647
+ reasoning?: { efforts: Array<{ id: string; name: string }>; defaultEffort?: string }
648
+ }>
649
+ }>
650
+ }> => {
651
+ const providers = ctx.llm.listProviders()
652
+ const groups: Array<{
653
+ id: string
654
+ name: string
655
+ models: Array<{
656
+ id: string
657
+ name: string
658
+ description?: string
659
+ reasoning?: { efforts: Array<{ id: string; name: string }>; defaultEffort?: string }
660
+ }>
661
+ }> = []
662
+ for (const provider of providers) {
663
+ const listed = await ctx.llm.listModels(provider.id)
664
+ const models: Array<{
665
+ id: string
666
+ name: string
667
+ description?: string
668
+ reasoning?: { efforts: Array<{ id: string; name: string }>; defaultEffort?: string }
669
+ }> = []
670
+ for (const model of listed) {
671
+ const entry: {
672
+ id: string
673
+ name: string
674
+ description?: string
675
+ reasoning?: { efforts: Array<{ id: string; name: string }>; defaultEffort?: string }
676
+ } = {
677
+ id: model.id,
678
+ name: model.name,
679
+ ...(model.description === undefined ? {} : { description: model.description }),
680
+ }
681
+ try {
682
+ const info = await ctx.llm.resolveModelInfo(provider.id, model.id)
683
+ if (info.reasoning !== undefined) {
684
+ entry.reasoning = {
685
+ efforts: info.reasoning.efforts.map((e) => ({ id: e.id, name: e.name })),
686
+ ...(info.reasoning.defaultEffort === undefined ? {} : { defaultEffort: info.reasoning.defaultEffort }),
687
+ }
688
+ }
689
+ } catch {
690
+ // A model whose metadata fails still lists, just without effort levels.
691
+ }
692
+ models.push(entry)
693
+ }
694
+ groups.push({ id: provider.id, name: provider.name, models })
695
+ }
696
+ return { groups }
697
+ }
698
+
699
+ /** Permission-preset options for the side-chat selector. */
700
+ const permissions = (): { options: Array<{ value: string; name: string; description?: string }>; current: string } => {
701
+ const select = ctx.permissionPresets.selectFor({})
702
+ return {
703
+ options: select.options.map((o) => ({ value: o.value, name: o.name, ...(o.description === undefined ? {} : { description: o.description }) })),
704
+ current: select.currentValue,
705
+ }
706
+ }
707
+
708
+ return {
709
+ 'sidechat.start': start,
710
+ 'sidechat.followup': followup,
711
+ 'sidechat.list': list,
712
+ 'sidechat.history': history,
713
+ 'sidechat.stop': stop,
714
+ 'sidechat.selectModel': selectModel,
715
+ 'sidechat.selectPermission': selectPermission,
716
+ 'sidechat.summarize': summarize,
717
+ 'sidechat.inject': inject,
718
+ 'sidechat.directory': directory,
719
+ 'sidechat.permissions': permissions,
720
+ 'sidechat.limits': limits,
721
+ 'sidechat.attachment': attachment,
722
+ 'sidechat.inherit': inherit,
723
+ 'sidechat.commands': commands,
724
+ 'sidechat.command': command,
725
+ 'sidechat.state': state,
726
+ 'sidechat.dispose': dispose,
727
+ // Side-chat preferences (settings service optional; absent → undefined).
728
+ 'settings.get': () => {
729
+ const settings = getSettings()
730
+ return settings?.get() ?? { value: undefined, revision: undefined }
731
+ },
732
+ 'settings.update': async (payload: unknown) => {
733
+ const settings = getSettings()
734
+ if (settings === undefined) {
735
+ throw new SidechatError('settings-rejected', 'the settings service is not mounted in this deployment', 503)
736
+ }
737
+ const record = payload as { patch?: unknown; expectedRevision?: unknown } | null
738
+ const patch = record?.patch
739
+ if (patch === null || typeof patch !== 'object' || Array.isArray(patch)) {
740
+ throw new SidechatError('bad-request', 'patch must be a plain object')
741
+ }
742
+ const expectedRevision = typeof record?.expectedRevision === 'number' ? record.expectedRevision : undefined
743
+ try {
744
+ return await settings.update(patch as Record<string, unknown>, expectedRevision)
745
+ } catch (error) {
746
+ if (error instanceof SettingsConflictError) {
747
+ throw new SidechatError('settings-conflict', error.message, 409)
748
+ }
749
+ throw new SidechatError('settings-rejected', error instanceof Error ? error.message : String(error), 400)
750
+ }
751
+ },
752
+ }
753
+ }
754
+
755
+ /** Read the connection row's trustedHosts, or empty (loopback-only fence). */
756
+ function trustedHostsOf(ctx: Context): string[] {
757
+ const loader = ctx.get('loader') as { entries?: () => Iterable<{ options: { name: string; config?: unknown } }> } | undefined
758
+ for (const entry of loader?.entries?.() ?? []) {
759
+ if (entry.options.name === 'connection') {
760
+ const config = entry.options.config as { trustedHosts?: string[] } | undefined
761
+ return config?.trustedHosts ?? []
762
+ }
763
+ }
764
+ return []
765
+ }
766
+
767
+ /** Host plugin body: register the /sidechat JSON API routes. */
768
+ export function apply(ctx: Context): void {
769
+ const sideChats = new Map<string, SidechatRecord>()
770
+ let settingsFace: SubchatSettingsFace | undefined
771
+
772
+ // Register the preferences namespace with the (optional) settings service.
773
+ // The client reads/writes it through the plugin's own fenced routes, since
774
+ // the DSH settings RPC domain only serves allowlisted namespaces.
775
+ ctx.inject(['settings'], (sctx: Context) => {
776
+ const ns = SUBCHAT_PREFS_NS as SettingsNamespace
777
+ const scope = sctx.settings.register(ns, PrefsSchema) as {
778
+ get(): SubchatPrefs
779
+ watch(cb: (next: SubchatPrefs, prev: SubchatPrefs) => void): () => void
780
+ }
781
+ const viewOf = (): { value?: unknown; revision?: number } => {
782
+ const descriptor = sctx.settings.describe({ redactSecrets: true }).find((c) => c.ns === ns)
783
+ return descriptor === undefined
784
+ ? { value: undefined, revision: undefined }
785
+ : { value: descriptor.value, revision: descriptor.revision }
786
+ }
787
+ settingsFace = {
788
+ get: viewOf,
789
+ update: async (patch, expectedRevision) => {
790
+ await sctx.settings.update(ns, patch, expectedRevision)
791
+ return viewOf()
792
+ },
793
+ }
794
+ void scope
795
+ })
796
+
797
+ const api = buildApi(ctx, sideChats, () => settingsFace)
798
+
799
+ // Tear every live side chat down with the plugin fiber.
800
+ ctx.effect(() => {
801
+ return () => {
802
+ for (const record of sideChats.values()) {
803
+ void record.handle.dispose().catch(() => {})
804
+ }
805
+ sideChats.clear()
806
+ }
807
+ }, 'dsh-side-chat: dispose side chats')
808
+
809
+ ctx.effect(() => ctx.webServer.register({
810
+ kind: 'prefix',
811
+ path: '/sidechat/api',
812
+ handler: async (req, res) => {
813
+ if (!isTrustedApiRequest(req, trustedHostsOf(ctx))) {
814
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden' } })
815
+ return
816
+ }
817
+ if (req.method !== 'POST') {
818
+ writeJson(res, 405, { ok: false, error: { code: 'method-error', message: 'method not allowed' } })
819
+ return
820
+ }
821
+ const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname
822
+ const method = pathname.startsWith('/sidechat/api/') ? pathname.slice('/sidechat/api/'.length) : undefined
823
+ if (method === undefined || method.includes('/')) {
824
+ writeJson(res, 404, { ok: false, error: { code: 'not-found', message: 'unknown sidechat API method' } })
825
+ return
826
+ }
827
+ const handler = (api as Record<string, (payload: unknown) => unknown>)[method]
828
+ if (handler === undefined) {
829
+ writeJson(res, 404, { ok: false, error: { code: 'not-found', message: `unknown sidechat API method "${method}"` } })
830
+ return
831
+ }
832
+ try {
833
+ const payload = await readJsonBody(req)
834
+ writeOk(res, await handler(payload))
835
+ } catch (error) {
836
+ writeError(res, error)
837
+ }
838
+ },
839
+ }), 'dsh-side-chat: /sidechat/api routes')
840
+ }