dsh-endpoint 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/server.js ADDED
@@ -0,0 +1,624 @@
1
+ import { createServer } from 'node:http'
2
+ import { createHash, randomUUID } from 'node:crypto'
3
+ import { homedir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { mergeConfig, readOverlay, registerAdminRoutes } from './admin.js'
6
+ import { ModelCatalog } from './dsh/catalog.js'
7
+ import { SessionMirror, toolResultEvent, userMessageEvent } from './mirror.js'
8
+ import { MappingStore } from './session/registry.js'
9
+ import { deriveSessionKey, resolveExternalKey, sessionKeyHash } from './session/key.js'
10
+ import { firstUserText, systemText, toDshMessages, toDshTools } from './openai/convert.js'
11
+ import { ApiError, badRequest, fromLlmFailure, notFound, toOpenAiBody } from './openai/errors.js'
12
+ import {
13
+ CompletionCollector,
14
+ SseWriter,
15
+ StopMatcher,
16
+ newCompletionId,
17
+ toFinishReason,
18
+ toOpenAiUsage,
19
+ } from './openai/stream.js'
20
+
21
+ /**
22
+ * Build the OpenAI-compatible request handler.
23
+ *
24
+ * Deliberately tolerant: unknown request fields are ignored (clients routinely
25
+ * send fields this build does not model), while the few that change semantics
26
+ * are rejected explicitly.
27
+ *
28
+ * @param {object} deps
29
+ * @param {object} deps.config
30
+ * @param {{ stream: Function, listProviders: Function, listModels: Function }} deps.llm
31
+ * @param {ModelCatalog} deps.catalog
32
+ * @param {SessionMirror | undefined} deps.mirror
33
+ * @param {MappingStore | undefined} deps.registry
34
+ * @param {(level: string, message: string) => void} deps.log
35
+ */
36
+ export function createHandler(deps) {
37
+ return async function handler(req, res) {
38
+ const requestId = `req_${randomUUID().replaceAll('-', '').slice(0, 24)}`
39
+ res.setHeader('x-request-id', requestId)
40
+ try {
41
+ const url = new URL(req.url ?? '/', 'http://localhost')
42
+ if (req.method === 'GET' && url.pathname === '/healthz') {
43
+ return sendJson(res, 200, {
44
+ ok: true,
45
+ object: 'dsh-endpoint.health',
46
+ providers: deps.catalog.providers().map((provider) => provider.id),
47
+ })
48
+ }
49
+ if (req.method === 'GET' && url.pathname === '/v1/models') {
50
+ return sendJson(res, 200, { object: 'list', data: await deps.catalog.list() })
51
+ }
52
+ if (req.method === 'GET' && url.pathname.startsWith('/v1/models/')) {
53
+ const id = decodeURIComponent(url.pathname.slice('/v1/models/'.length))
54
+ const found = (await deps.catalog.list()).find((model) => model.id === id)
55
+ if (found === undefined) throw notFound(`model "${id}" not found`)
56
+ return sendJson(res, 200, found)
57
+ }
58
+ if (req.method === 'POST' && url.pathname === '/v1/chat/completions') {
59
+ const body = await readJson(req, deps.config.limits.maxBodyBytes)
60
+ return await handleChat(req, res, body, deps, requestId)
61
+ }
62
+ if (req.method === 'POST' && url.pathname === '/v1/completions') {
63
+ throw badRequest('the legacy /v1/completions endpoint is not supported; use /v1/chat/completions')
64
+ }
65
+ throw new ApiError(404, 'invalid_request_error', `unknown route ${req.method} ${url.pathname}`)
66
+ } catch (error) {
67
+ const apiError = error instanceof ApiError ? error : new ApiError(500, 'api_error', String(error?.message ?? error))
68
+ if (res.headersSent) {
69
+ res.end()
70
+ return
71
+ }
72
+ sendJson(res, apiError.status, toOpenAiBody(apiError, requestId))
73
+ }
74
+ }
75
+ }
76
+
77
+ async function handleChat(req, res, body, deps, requestId) {
78
+ const { config, catalog, mirror, registry, log } = deps
79
+ if (body === null || typeof body !== 'object') throw badRequest('request body must be a JSON object')
80
+
81
+ if (body.n !== undefined && body.n !== 1) throw badRequest('`n` values above 1 are not supported', 'n')
82
+ if (body.tool_choice !== undefined && body.tool_choice !== null && body.tool_choice !== 'auto') {
83
+ throw badRequest('only `tool_choice: "auto"` is supported', 'tool_choice')
84
+ }
85
+
86
+ const route = catalog.resolve(body.model)
87
+ const wantsStream = body.stream === true
88
+ const messages = await toDshMessages(body.messages, route, { saveImages: deps.saveImages })
89
+ const tools = toDshTools(body.tools)
90
+
91
+ const identity =
92
+ resolveExternalKey({ headers: req.headers, body, headerName: config.session.header }) ??
93
+ deriveSessionKey({
94
+ model: route.id,
95
+ systemText: systemText(body.messages),
96
+ firstUserText: firstUserSignal(body.messages),
97
+ deriveFrom: config.session.deriveFrom,
98
+ }) ?? { key: `auto:mint:${randomUUID()}`, source: 'minted' }
99
+
100
+ const hash = sessionKeyHash(identity.key)
101
+ const turn = await prepareTurn({ body, converted: messages, identity, hash, route, deps })
102
+ res.setHeader(config.session.header, turn.sessionId)
103
+
104
+ if (turn.state !== undefined) {
105
+ try {
106
+ mirror.beginTurn(turn.state, turn.events)
107
+ } catch (error) {
108
+ log('warn', `could not open mirrored turn: ${String(error?.message ?? error)}`)
109
+ turn.state = undefined
110
+ }
111
+ }
112
+
113
+ const collector = new CompletionCollector()
114
+ const stop = new StopMatcher(normaliseStop(body.stop))
115
+ const controller = new AbortController()
116
+ const abort = () => controller.abort()
117
+ req.on('close', abort)
118
+
119
+ const created = Math.floor(Date.now() / 1000)
120
+ const completionId = newCompletionId()
121
+ const writer = wantsStream
122
+ ? beginSse(res, { id: completionId, created, model: route.id, sessionHeader: config.session.header, sessionId: turn.sessionId })
123
+ : undefined
124
+
125
+ const options = {
126
+ provider: route.provider,
127
+ model: route.model,
128
+ messages,
129
+ // Provider-side cache/routing affinity. Several gateways (opencode-go
130
+ // among them) reject requests that carry no session identity, and pi-ai
131
+ // forwards this value as their affinity header. It is derived from the
132
+ // conversation key, so it is stable across requests and restarts.
133
+ sessionId: affinityId(identity.key),
134
+ signal: controller.signal,
135
+ }
136
+ if (tools !== undefined) options.tools = tools
137
+ if (Number.isFinite(body.temperature)) options.temperature = body.temperature
138
+ const requestedMax = Number.isFinite(body.max_tokens) ? body.max_tokens : body.max_completion_tokens
139
+ const maxTokens = Number.isFinite(requestedMax)
140
+ ? Math.max(1, Math.min(requestedMax, config.limits.maxOutputTokens))
141
+ : undefined
142
+ if (maxTokens !== undefined) options.maxTokens = maxTokens
143
+
144
+ let stopHit = false
145
+ let failure
146
+ try {
147
+ for await (const chunk of deps.llm.stream(options)) {
148
+ if (chunk?.type === 'text-delta' && stop.active) {
149
+ const emitted = stop.push(chunk.text)
150
+ if (emitted.text.length > 0) {
151
+ collector.push({ ...chunk, text: emitted.text })
152
+ writer?.chunk({ ...chunk, text: emitted.text })
153
+ }
154
+ if (emitted.hit) {
155
+ stopHit = true
156
+ break
157
+ }
158
+ continue
159
+ }
160
+ collector.push(chunk)
161
+ writer?.chunk(chunk)
162
+ if (chunk?.type === 'finish' && chunk.reason?.kind === 'error') failure = chunk.reason.failure
163
+ }
164
+ if (!stopHit && stop.active) {
165
+ const tail = stop.flush()
166
+ if (tail.length > 0) {
167
+ collector.push({ type: 'text-delta', index: 0, text: tail })
168
+ writer?.chunk({ type: 'text-delta', index: 0, text: tail })
169
+ }
170
+ }
171
+ } catch (error) {
172
+ failure = { code: error?.code ?? 'INTERNAL', message: String(error?.message ?? error) }
173
+ } finally {
174
+ req.off('close', abort)
175
+ }
176
+
177
+ const usage = collector.usage
178
+ if (failure !== undefined) {
179
+ const apiError = fromLlmFailure(failure)
180
+ await finishTurn({ turn, hash, deps, route, collector, usage, failure: apiError })
181
+ log('warn', `model call failed: ${apiError.message}`)
182
+ if (writer !== undefined) {
183
+ writer.writeError(toOpenAiBody(apiError, requestId))
184
+ writer.end()
185
+ return undefined
186
+ }
187
+ throw apiError
188
+ }
189
+
190
+ const finishReason = stopHit ? 'stop' : toFinishReason(collector.finishReason)
191
+ if (writer !== undefined) {
192
+ writer.finish(collector.finishReason)
193
+ if (body.stream_options?.include_usage === true) writer.usage(usage)
194
+ writer.end()
195
+ } else {
196
+ const message = { role: 'assistant', content: collector.text }
197
+ if (collector.reasoning.length > 0) message.reasoning_content = collector.reasoning
198
+ if (collector.toolCalls.length > 0) {
199
+ message.tool_calls = collector.toolCalls.map((call, index) => ({
200
+ index,
201
+ id: call.id,
202
+ type: 'function',
203
+ function: { name: call.name, arguments: call.arguments },
204
+ }))
205
+ message.content = collector.text.length > 0 ? collector.text : null
206
+ }
207
+ sendJson(res, 200, {
208
+ id: completionId,
209
+ object: 'chat.completion',
210
+ created,
211
+ model: route.id,
212
+ choices: [{ index: 0, message, finish_reason: finishReason }],
213
+ usage: toOpenAiUsage(usage),
214
+ })
215
+ }
216
+
217
+ await finishTurn({ turn, hash, deps, route, collector, usage })
218
+ return undefined
219
+ }
220
+
221
+ /**
222
+ * Resolve (or create) the mirror session for this conversation and work out
223
+ * which client messages are new since the last recorded turn.
224
+ */
225
+ async function prepareTurn({ body, converted, identity, hash, route, deps }) {
226
+ const { mirror, registry, log } = deps
227
+ // Keep the raw message for the prefix hash (stable across re-uploads) and the
228
+ // converted one for the event, so images land in the mirror as attachment
229
+ // references instead of being flattened to text.
230
+ const sides = []
231
+ body.messages.forEach((message, index) => {
232
+ if (message?.role !== 'user' && message?.role !== 'tool') return
233
+ sides.push({ raw: message, converted: converted[index] })
234
+ })
235
+ const hashes = sides.map((side) => hashMessage(side.raw))
236
+
237
+ if (mirror === undefined || registry === undefined) {
238
+ return { sessionId: echoable(identity.key, hash), state: undefined, events: [], hashes }
239
+ }
240
+
241
+ try {
242
+ const entry = registry.get(hash)
243
+ if (entry !== undefined) {
244
+ const recorded = entry.userHashes ?? []
245
+ const prefixMatches = recorded.every((recordedHash, index) => hashes[index] === recordedHash)
246
+ if (prefixMatches) {
247
+ let state
248
+ try {
249
+ state = await mirror.ensure(entry)
250
+ } catch (error) {
251
+ if (!isMissingSession(error)) throw error
252
+ log('warn', `mirrored session ${entry.dshSessionId} is gone; creating a replacement`)
253
+ const created = await mirror.create()
254
+ await registry.bind(hash, {
255
+ externalKey: identity.key,
256
+ source: identity.source,
257
+ dshSessionId: created.dshSessionId,
258
+ workspacePath: mirror.workspacePath,
259
+ model: route.id,
260
+ })
261
+ return {
262
+ sessionId: created.dshSessionId,
263
+ state: created.state,
264
+ events: sides.map((side) => toEvent(side.converted)),
265
+ hashes,
266
+ }
267
+ }
268
+ return {
269
+ sessionId: entry.dshSessionId,
270
+ state,
271
+ events: sides.slice(recorded.length).map((side) => toEvent(side.converted)),
272
+ hashes,
273
+ }
274
+ }
275
+ log('warn', `client history diverged for "${identity.key}"; starting a new DSH session`)
276
+ }
277
+ const created = await mirror.create()
278
+ await registry.bind(hash, {
279
+ externalKey: identity.key,
280
+ source: identity.source,
281
+ dshSessionId: created.dshSessionId,
282
+ workspacePath: mirror.workspacePath,
283
+ model: route.id,
284
+ })
285
+ return {
286
+ sessionId: created.dshSessionId,
287
+ state: created.state,
288
+ events: sides.map((side) => toEvent(side.converted)),
289
+ hashes,
290
+ }
291
+ } catch (error) {
292
+ log('warn', `session mirror unavailable for this request: ${String(error?.message ?? error)}`)
293
+ return { sessionId: echoable(identity.key, hash), state: undefined, events: [], hashes }
294
+ }
295
+ }
296
+
297
+ async function finishTurn({ turn, hash, deps, route, collector, usage, failure }) {
298
+ const { mirror, registry, log } = deps
299
+ if (mirror === undefined || registry === undefined || turn.state === undefined) return
300
+ try {
301
+ if (failure === undefined) {
302
+ mirror.completeTurn(turn.state, {
303
+ blocks: collector.blocks,
304
+ chunks: collector.chunks,
305
+ usage,
306
+ provider: route.provider,
307
+ model: route.model,
308
+ })
309
+ } else {
310
+ mirror.failTurn(turn.state, { message: failure.message, code: failure.code ?? 'api_error' })
311
+ }
312
+ const entry = registry.get(hash)
313
+ if (entry !== undefined) {
314
+ await registry.update(hash, {
315
+ turns: turn.state.turn,
316
+ requests: entry.requests + 1,
317
+ inputTokens: entry.inputTokens + (usage?.inputTokens ?? 0),
318
+ outputTokens: entry.outputTokens + (usage?.outputTokens ?? 0),
319
+ userHashes: turn.hashes,
320
+ model: route.id,
321
+ })
322
+ }
323
+ void mirror.flush(turn.state)
324
+ } catch (error) {
325
+ log('warn', `could not record mirrored turn: ${String(error?.message ?? error)}`)
326
+ }
327
+ }
328
+
329
+ /** Deterministic UUID-shaped affinity id for one conversation. */
330
+ function affinityId(key) {
331
+ const h = createHash('sha256').update(`dsh-endpoint-affinity\u0000${key}`).digest('hex')
332
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-4${h.slice(13, 16)}-a${h.slice(17, 20)}-${h.slice(20, 32)}`
333
+ }
334
+
335
+ /** Header values must be a single ASCII line; fall back to the opaque hash. */
336
+ function echoable(key, hash) {
337
+ return /^[\x20-\x7e]{1,200}$/.test(key) ? key : `unmirrored-${hash.slice(0, 8)}`
338
+ }
339
+
340
+ /**
341
+ * The mirror records text only; media parts become a stable placeholder so an
342
+ * image-only message still produces a valid `user/message` event.
343
+ */
344
+ /** Converted DSH message -> mirror surface event (keeps image/file references). */
345
+ function toEvent(message) {
346
+ if (message.role === 'tool') {
347
+ return toolResultEvent({
348
+ callId: message.toolCallId,
349
+ text: textOfBlocks(message.content),
350
+ isError: message.isError === true,
351
+ })
352
+ }
353
+ return userMessageEvent(message.content)
354
+ }
355
+
356
+ function textOfBlocks(content) {
357
+ if (!Array.isArray(content)) return ''
358
+ return content
359
+ .filter((block) => block?.type === 'text' && typeof block.text === 'string')
360
+ .map((block) => block.text)
361
+ .join('')
362
+ }
363
+
364
+ /**
365
+ * Fingerprint input for the first user message. Text is preferred; an
366
+ * image-only opener falls back to a hash of its parts so the conversation key
367
+ * stays stable instead of minting a new session per request.
368
+ */
369
+ function firstUserSignal(messages) {
370
+ const text = firstUserText(messages)
371
+ if (text.length > 0) return text
372
+ const first = messages.find((message) => message?.role === 'user')
373
+ if (first === undefined) return ''
374
+ const parts = JSON.stringify(first.content ?? '')
375
+ return `parts:${createHash('sha256').update(parts).digest('hex').slice(0, 32)}`
376
+ }
377
+
378
+
379
+ function hashMessage(message) {
380
+ return createHash('sha256')
381
+ .update(JSON.stringify([message.role, message.content ?? null, message.tool_call_id ?? null]))
382
+ .digest('hex')
383
+ .slice(0, 32)
384
+ }
385
+
386
+ function normaliseStop(value) {
387
+ if (typeof value === 'string') return [value]
388
+ if (Array.isArray(value)) return value.filter((item) => typeof item === 'string')
389
+ return []
390
+ }
391
+
392
+ function beginSse(res, { id, created, model, sessionHeader, sessionId }) {
393
+ res.writeHead(200, {
394
+ 'content-type': 'text/event-stream; charset=utf-8',
395
+ 'cache-control': 'no-cache, no-transform',
396
+ 'x-accel-buffering': 'no',
397
+ [sessionHeader]: sessionId,
398
+ })
399
+ const writer = new SseWriter({ res, id, created, model })
400
+ writer.start()
401
+ return writer
402
+ }
403
+
404
+ function sendJson(res, status, payload) {
405
+ const body = JSON.stringify(payload)
406
+ res.writeHead(status, {
407
+ 'content-type': 'application/json; charset=utf-8',
408
+ 'content-length': Buffer.byteLength(body),
409
+ })
410
+ res.end(body)
411
+ }
412
+
413
+ async function readJson(req, limit) {
414
+ const chunks = []
415
+ let size = 0
416
+ for await (const chunk of req) {
417
+ size += chunk.length
418
+ if (size > limit) throw badRequest(`request body exceeds ${limit} bytes`)
419
+ chunks.push(chunk)
420
+ }
421
+ if (chunks.length === 0) throw badRequest('request body is empty')
422
+ try {
423
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'))
424
+ } catch {
425
+ throw badRequest('request body is not valid JSON')
426
+ }
427
+ }
428
+
429
+ /**
430
+ * Start the listener and return its disposer.
431
+ *
432
+ * @param {object} ctx
433
+ * @param {object} config
434
+ * @param {{ log: (level: string, message: string) => void }} options
435
+ */
436
+ export async function startEndpoint(ctx, config, options) {
437
+ const log = options.log
438
+ const llm = ctx.get('llm')
439
+ if (llm === undefined) throw new Error('dsh-endpoint: ctx.llm is not available in this profile')
440
+
441
+ const root = options.stateRoot ?? resolveStateRoot(ctx)
442
+ const overlay = await readOverlay(root)
443
+ const effective = mergeConfig(config, overlay)
444
+
445
+ const catalog = new ModelCatalog(llm, {
446
+ aliases: effective.modelAliases,
447
+ defaultModel: effective.defaultModel,
448
+ log,
449
+ })
450
+
451
+ let mirror
452
+ let registry
453
+ if (effective.session.mirror === true) {
454
+ const workspacePath = effective.session.workspacePath || join(root, 'conversations')
455
+ registry = new MappingStore(join(root, 'state.json'), { log })
456
+ await registry.open()
457
+ mirror = new SessionMirror(ctx, { workspacePath, log })
458
+ }
459
+
460
+ let sweepTimer
461
+ if (mirror !== undefined && registry !== undefined) {
462
+ const sweep = () => void sweepExpired(ctx, registry, mirror, effective, log)
463
+ sweepTimer = setInterval(sweep, 60 * 60 * 1000)
464
+ sweepTimer.unref?.()
465
+ const first = setTimeout(sweep, 10_000)
466
+ first.unref?.()
467
+ }
468
+
469
+ const attachments = ctx.get('attachments')
470
+ const saveImages =
471
+ attachments === undefined ? undefined : (inputs) => attachments.saveImages(inputs)
472
+ const handler = createHandler({
473
+ config: effective,
474
+ llm,
475
+ catalog,
476
+ mirror,
477
+ registry,
478
+ log,
479
+ saveImages,
480
+ })
481
+ let current = await listenOn(handler, effective.host, effective.port)
482
+ logListening(effective, current.port, log)
483
+
484
+ // Serialized listener swaps: the settings page can move host/port without a
485
+ // plugin reload. A failed bind restores the previous address instead of
486
+ // leaving the endpoint down.
487
+ let rebindChain = Promise.resolve()
488
+ const rebind = (nextHost, nextPort) => {
489
+ rebindChain = rebindChain.then(async () => {
490
+ const previous = { host: effective.host, port: current.port }
491
+ await closeServer(current.server)
492
+ try {
493
+ current = await listenOn(handler, nextHost, nextPort)
494
+ } catch (error) {
495
+ try {
496
+ current = await listenOn(handler, previous.host, previous.port)
497
+ } catch {
498
+ current = { server: undefined, port: previous.port }
499
+ }
500
+ throw new Error(`cannot listen on ${formatHost(nextHost)}:${nextPort}: ${error?.message ?? error}`)
501
+ }
502
+ effective.host = nextHost
503
+ effective.port = nextPort
504
+ logListening(effective, current.port, log)
505
+ })
506
+ return rebindChain
507
+ }
508
+
509
+ registerAdminRoutes(ctx, {
510
+ root,
511
+ log,
512
+ getConfig: () => effective,
513
+ onSave: async (next, patch) => {
514
+ const hostChanged = patch.host !== undefined && patch.host !== effective.host
515
+ const portChanged = patch.port !== undefined && Number(patch.port) !== effective.port
516
+ if (hostChanged || portChanged) await rebind(next.host, next.port)
517
+ for (const key of Object.keys(next)) effective[key] = next[key]
518
+ catalog.aliases = next.modelAliases ?? {}
519
+ catalog.defaultModel = next.defaultModel ?? ''
520
+ },
521
+ getState: async () => ({
522
+ url: `http://${formatHost(effective.host)}:${current.port}`,
523
+ host: effective.host,
524
+ port: current.port,
525
+ providers: catalog.providers().map((provider) => provider.id),
526
+ sessions: registry?.list().length ?? 0,
527
+ mirror: effective.session.mirror,
528
+ stateRoot: root,
529
+ }),
530
+ })
531
+
532
+ const dispose = async () => {
533
+ if (sweepTimer !== undefined) clearInterval(sweepTimer)
534
+ await rebindChain.catch(() => {})
535
+ await closeServer(current.server)
536
+ await mirror?.close()
537
+ await registry?.close()
538
+ }
539
+
540
+ return {
541
+ port: current.port,
542
+ url: `http://${formatHost(effective.host)}:${current.port}`,
543
+ dispose,
544
+ }
545
+ }
546
+
547
+ function listenOn(handler, host, port) {
548
+ return new Promise((resolve, reject) => {
549
+ const server = createServer(handler)
550
+ server.keepAliveTimeout = 65_000
551
+ server.once('error', reject)
552
+ server.listen(port, host, () => {
553
+ server.off('error', reject)
554
+ const address = server.address()
555
+ resolve({ server, port: typeof address === 'object' && address !== null ? address.port : port })
556
+ })
557
+ })
558
+ }
559
+
560
+ async function closeServer(server) {
561
+ if (server === undefined) return
562
+ await new Promise((resolve) => {
563
+ server.close(() => resolve())
564
+ server.closeAllConnections?.()
565
+ })
566
+ }
567
+
568
+ function logListening(config, port, log) {
569
+ log('info', `dsh-endpoint listening on http://${formatHost(config.host)}:${port}`)
570
+ if (config.host === '0.0.0.0') {
571
+ log(
572
+ 'warn',
573
+ 'dsh-endpoint is bound to 0.0.0.0 without authentication; anyone who can reach this port can spend your model quota',
574
+ )
575
+ }
576
+ }
577
+
578
+ async function sweepExpired(ctx, registry, mirror, config, log) {
579
+ try {
580
+ const expired = await registry.expire(config.session.mappingTtlDays)
581
+ for (const entry of expired) {
582
+ const state = mirror.states.get(entry.dshSessionId)
583
+ if (state !== undefined) {
584
+ await mirror.flush(state)
585
+ await state.handle?.close?.().catch(() => {})
586
+ mirror.states.delete(entry.dshSessionId)
587
+ }
588
+ if (config.session.archiveOnExpire === true) {
589
+ try {
590
+ await ctx.get('workspaceRegistry')?.archiveSession?.(entry.dshSessionId)
591
+ } catch (error) {
592
+ log('warn', `could not archive ${entry.dshSessionId}: ${String(error?.message ?? error)}`)
593
+ }
594
+ }
595
+ log('info', `expired mapping ${entry.externalKey} -> ${entry.dshSessionId}`)
596
+ }
597
+ } catch (error) {
598
+ log('warn', `mapping sweep failed: ${String(error?.message ?? error)}`)
599
+ }
600
+ }
601
+
602
+ function isMissingSession(error) {
603
+ const name = String(error?.name ?? '')
604
+ const message = String(error?.message ?? '')
605
+ return name.includes('NotFound') || message.includes('not found')
606
+ }
607
+
608
+ function resolveStateRoot(ctx) {
609
+ try {
610
+ const homePath = ctx.get('dshHomePath')
611
+ if (typeof homePath === 'function') {
612
+ const resolved = homePath('dsh-endpoint')
613
+ if (typeof resolved === 'string' && resolved.length > 0) return resolved
614
+ }
615
+ } catch {
616
+ // fall through to DSH_HOME
617
+ }
618
+ const base = process.env.DSH_HOME ?? join(homedir(), '.dsh')
619
+ return join(base, 'dsh-endpoint')
620
+ }
621
+
622
+ function formatHost(host) {
623
+ return host.includes(':') ? `[${host}]` : host
624
+ }