/**
* dsh-float-window — Host 半(标准 DSH / cordis 插件)
*
* 职责:
* 1. 把原生浮窗需要的接入信息写成 `%DSH_HOME%\float-window\endpoint.json`
* (WebView2 壳读它来连这里;因为壳是独立进程,拿不到 ctx)。
* 2. 提供 SSE 数据通道 `/dsh-float-window/events`:会话消息 + 计费指标快照。
* 3. 提供 `/dsh-float-window/cmd`:浮窗 → 宿主的控制命令。
* 4. 注册 `float_window` 工具,让 Agent 能自己把浮窗调出来 / 收起 / 置顶 / 全屏。
*
* 设计原则:宿主内部 API 一律"探测式"使用(ctx.get(...) + try/catch),
* 任何一个服务缺失都只是少几个指标,绝不能让插件加载失败拖垮 DSH。
*/
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { createRequire } from 'node:module'
import { randomUUID } from 'node:crypto'
import { fileURLToPath } from 'node:url'
export const name = 'dsh-float-window'
export const inject = ['webServer']
const HERE = path.dirname(fileURLToPath(import.meta.url))
const REPO = path.resolve(HERE, '..')
const ROUTE = '/dsh-float-window'
function dshHome() {
return process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
}
// ---------------------------------------------------------------- 配置 schema
// 0.1.7 的设置模型是"编辑插件自身的 Config(profile 条目配置)",所以这里导出一个
// schemastery schema,DSH 的「设置 → 插件」就能渲染出悬浮窗的配置卡片。
// 难点:插件用 link: 安装时自身目录没有 node_modules,裸 import 会失败 ——
// 用 createRequire 以"宿主入口 / profile 目录"为基准去解析。
/** 通用"宿主侧模块"解析:link: 安装的插件自身没有 node_modules,必须借宿主的 */
function loadHostModule(spec) {
const tries = [
() => process.argv[1] && createRequire(process.argv[1]),
() => createRequire(path.join(dshHome(), 'profiles', 'desktop', 'package.json')),
() => createRequire(path.join(dshHome(), 'profiles', 'web', 'package.json')),
]
for (const make of tries) {
try {
const req = make()
if (!req) continue
const m = req(spec)
// 返回模块命名空间本身:default 是类(ToolRuntime)时具名导出 defineTool 会被吞掉
if (m && (m.defineTool || m.default || m.object)) return m
} catch { }
}
return null
}
function loadSchemastery() {
const m = loadHostModule('@deepseek-ai/schemastery')
const z = m?.default ?? m
return z?.object ? z : null
}
const z = loadSchemastery()
const floatSchema = z
? z.object({
topmost: z.boolean().default(true).description('始终置顶'),
posLock: z.boolean().default(false).description('固定位置(图钉)'),
sizeLock: z.boolean().default(false).description('锁定尺寸(小锁)'),
exclude: z.boolean().default(true).description('抓屏时隐藏本窗'),
opacity: z.number().min(0.3).max(1).default(0.92).description('不透明度(毛玻璃时是霜化浓度)'),
scale: z.number().min(0.8).max(1.5).default(1).description('界面缩放'),
backdrop: z.union(['opaque', 'translucent', 'acrylic', 'mica', 'blur']).default('opaque').description('背景效果'),
tool: z.boolean().default(true).description('给 Agent 注册 float_window 工具(关掉则模型看不到它)'),
})
: undefined
/** 浮窗设置项(DSH 设置页写进 profile 条目 config,这里读取并下发给浮窗) */
export const Config = floatSchema
/** 从 content 数组/字符串里取纯文本 */
function textOf(content) {
if (typeof content === 'string') return content
if (Array.isArray(content)) {
return content
.map((c) => (typeof c === 'string' ? c : (c?.type === 'text' || c?.text ? String(c.text ?? '') : '')))
.join('')
}
return ''
}
/**
* 会话事件 → 浮窗消息行的宽松映射。
* 实测(dsh 0.1.x)事件载荷在 `event.data` 下:
* user/message { data: { content:[{type:'text',text}], role, source:{kind:'user'|'plugin'} , id } }
* assistant/chunk { data: { turn, step, chunk:{ type, text?, reason? } } }
* assistant/message{ data: { content:[...] } }
* tool/call { data: { name, args } }
* 插件注入的消息(source.kind === 'plugin',例如系统提示快照)不进镜像。
*/
function toRow(event) {
if (!event || typeof event !== 'object') return null
const type = String(event.type || '')
const d = event.data || event
const src = d.source || event.source
if (type === 'user/message') {
if (src && src.kind && src.kind !== 'user') return null
const text = textOf(d.content ?? d.message?.content ?? d.text)
return text ? { role: 'user', text: clip(text) } : null
}
if (type === 'assistant/message') {
const text = textOf(d.content ?? d.message?.content ?? d.text)
return text ? { role: 'assistant', text: clip(text) } : null
}
if (type === 'assistant/chunk') {
const c = d.chunk || {}
const kind = String(c.type || '')
const piece = c.text ?? c.delta ?? c.value ?? (kind.includes('text') ? c.content : '')
if (piece) return { role: 'assistant', text: String(piece), streaming: true }
if (kind === 'finish' && c.reason?.kind === 'error') {
return { role: 'assistant', text: '⚠️ ' + short(c.reason.failure ?? c.reason, 240) }
}
return null
}
if (type === 'tool/call') {
return { role: 'tool', text: `${d.name || d.tool || 'tool'}(${short(d.args ?? d.input ?? '', 160)})` }
}
return null
}
function clip(text, n = 4000) {
const s = String(text)
return s.length > n ? s.slice(0, n) + '…' : s
}
function short(v, n = 120) {
try {
const s = typeof v === 'string' ? v : JSON.stringify(v)
return s && s.length > n ? s.slice(0, n) + '…' : s || ''
} catch { return '' }
}
/** 脱敏采样:保留结构与短字符串,避免把长内容/密钥写进状态文件 */
function sanitize(v, depth = 0) {
if (v == null || typeof v === 'number' || typeof v === 'boolean') return v
if (typeof v === 'string') return v.length > 200 ? v.slice(0, 200) + '…' : v
if (depth >= 4) return Array.isArray(v) ? `[array ${v.length}]` : '[object]'
if (Array.isArray(v)) return v.slice(0, 6).map((x) => sanitize(x, depth + 1))
const out = {}
for (const [k, val] of Object.entries(v)) out[k] = sanitize(val, depth + 1)
return out
}
const SAMPLE_TYPES = new Set(['user/message', 'assistant/chunk', 'assistant/message', 'tool/call', 'tool/result', 'session/title', 'request/header', 'request/context'])
export function apply(ctx, config) {
// 浮窗设置:DSH 设置页写进 profile 条目 config 的值 + 默认值
const floatCfg = Object.assign(
{ topmost: true, posLock: false, sizeLock: false, exclude: true, opacity: 0.92, scale: 1, backdrop: 'opaque', tool: true, button: true },
(config && typeof config === 'object') ? config : {},
)
// 运行状态与自检日志落盘:插件跑在 DSH 内部,控制台不一定看得到,
// 有了这个文件(和 /health)才能在用户机器上远程排障。
const notes = []
const seenTypes = new Map() // 事件类型直方图:写进状态文件,便于在用户机器上核对字段名
const samples = {} // 每种关键事件的脱敏样本(最多 3 条),用于对齐不同 DSH 版本的字段名
let lastRow = null
const statusFile0 = path.join(dshHome(), 'float-window', 'plugin-status.json')
const status = { version: '0.1.0', pid: process.pid, startedAt: new Date().toISOString(), tool: false, settings: false, notes }
const writeStatus = () => {
try {
status.notes = notes.slice(-40)
status.eventTypes = Object.fromEntries([...seenTypes].slice(-40))
status.samples = samples
status.lastRow = lastRow
fs.mkdirSync(path.dirname(statusFile0), { recursive: true })
fs.writeFileSync(statusFile0, JSON.stringify(status, null, 2))
} catch { }
}
const log = (...a) => {
const line = a.map((x) => (typeof x === 'string' ? x : short(x, 200))).join(' ')
notes.push(`${new Date().toISOString().slice(11, 19)} ${line}`)
if (notes.length > 80) notes.shift()
try { (ctx.logger?.info || console.log)('[float-window]', ...a) } catch { }
writeStatus()
}
// ---------------------------------------------------------------- endpoint
const endpointFile = path.join(dshHome(), 'float-window', 'endpoint.json')
const writeEndpoint = () => {
try {
fs.mkdirSync(path.dirname(endpointFile), { recursive: true })
const data = {
baseUrl: `http://127.0.0.1:${ctx.webServer.port}`,
host: ctx.webServer.host,
pid: process.pid,
updatedAt: new Date().toISOString(),
}
fs.writeFileSync(endpointFile, JSON.stringify(data, null, 2))
log('endpoint written', data.baseUrl, '->', endpointFile)
} catch (e) { log('endpoint write failed:', String(e?.message || e)) }
}
writeEndpoint()
ctx.effect(() => () => { try { fs.rmSync(endpointFile, { force: true }) } catch { } })
// ---------------------------------------------------------------- 版本自适应的宿主调用
// dsh 0.1.x 早期:ctx.apiProxy.sessions.*(信封 { rpcId, payload })
// dsh 0.1.7+ :ctx.sessionController.*(Typert Remote 的宿主面,扁平信封)
// 两个都试,谁在就用谁;都没有就返回可读错误,绝不让插件抛出去。
async function hostCall(method, payload) {
const sc = ctx.get('sessionController')
if (sc && typeof sc[method] === 'function') {
const flat = { ...payload }
delete flat.rpcId
if (!flat.requestId) flat.requestId = randomUUID()
// 0.1.7 的 Remote 签名是 (request, signal);signal 缺失时宿主内部会
// "Cannot read properties of undefined (reading 'throwIfAborted')"
const r = await sc[method](flat, new AbortController().signal)
return { value: r?.value ?? r, via: 'sessionController.' + method }
}
const api = ctx.get('apiProxy')
if (api?.sessions && typeof api.sessions[method] === 'function') {
const r = await api.sessions[method]({ rpcId: randomUUID(), payload })
return { value: r?.result?.value ?? r?.result ?? r, via: 'apiProxy.sessions.' + method }
}
throw new Error(`no host API for "${method}" (tried sessionController / apiProxy)`)
}
const MAX_ROWS = 120
const rows = new Map() // sessionId -> row[]
const counts = new Map() // sessionId -> 事件计数兜底(没有 sessionProjection 也能出数字)
const titles = new Map() // sessionId -> 会话标题(来自 session/title 事件)
const contextWindows = new Map() // sessionId -> 上下文窗口(来自 request/context 事件)
let lastSessionId = null
let pinnedSessionId = null // 用户在浮窗里选中的会话:多会话并行时不再被最新事件抢走
function counter(id) {
let c = counts.get(id)
if (!c) { c = { turns: 0, steps: 0, outTokens: 0, decodeMs: 0, stepStart: 0, lastTps: null }; counts.set(id, c) }
return c
}
ctx.on('session/event', (session, event) => {
try {
const id = session?.id || session?.sessionId || 'unknown'
if (!pinnedSessionId) lastSessionId = id
const c = counter(id)
const type = String(event?.type || '')
const d2 = () => event?.data || event || {}
seenTypes.set(type, (seenTypes.get(type) || 0) + 1)
if (SAMPLE_TYPES.has(type) && (samples[type]?.length ?? 0) < 3) {
(samples[type] ||= []).push(sanitize(event))
}
if (type === 'turn/end') c.turns++
else if (type === 'request/header') {
// 记录模型实际会收到的工具 schema 摘要:这是"schema 是否合法"的最终证据
const h = (event?.data || event)?.header
if (Array.isArray(h?.tools)) {
status.toolSchemas = h.tools.slice(0, 12).map((t) => ({
name: t?.name,
parametersType: t?.parameters?.type ?? null,
required: Array.isArray(t?.parameters?.required) ? t.parameters.required : null,
}))
}
}
else if (type === 'request/context') {
// 真机上 provider 会在这条事件里给出模型上下文窗口(实测 deepseek-flash = 1000000)
const cw = (event?.data || event)?.contextWindow
if (typeof cw === 'number' && cw > 0) contextWindows.set(id, cw)
}
else if (type === 'session/title') {
const t = d2(event)?.title
if (t) titles.set(id, String(t))
}
else if (type === 'step/start') c.stepStart = Date.now()
else if (type === 'step/end') c.steps++
else if (type === 'assistant/message') {
const usage = event?.usage || event?.message?.usage || {}
const out = usage.outputTokens ?? usage.output_tokens ?? usage.completionTokens
const ms = c.stepStart ? Date.now() - c.stepStart : 0
if (out && ms > 0) { c.outTokens += out; c.decodeMs += ms; c.lastTps = out / (ms / 1000) }
}
const row = toRow(event)
lastRow = row ? { type, row } : { type, row: null }
if (!row) return
const list = rows.get(id) || []
const last = list[list.length - 1]
if (row.streaming && last && last.role === row.role && last.streaming) last.text += row.text
else list.push(row)
while (list.length > MAX_ROWS) list.shift()
rows.set(id, list)
} catch { }
})
// ---------------------------------------------------------------- 指标
let meterKeysLogged = false
function metricsFor(session) {
const id = session?.id || lastSessionId || 'unknown'
const c = counts.get(id) || { turns: 0, steps: 0, outTokens: 0, decodeMs: 0, lastTps: null }
const out = {
turns: c.turns || null,
steps: c.steps || null,
tps: c.lastTps,
tokens: null,
cacheHit: null,
contextUsed: null,
}
// 有 session-projection 就用它(更权威),否则用事件计数兜底
try {
const proj = ctx.get('sessionProjection')
const st = proj?.get?.(session, 'sessionStats') || proj?.peek?.(session, 'sessionStats')
if (st) {
if (st.turns != null) out.turns = st.turns
if (st.steps != null) out.steps = st.steps
if (st.decodeTokens && st.decodeMs) out.tps = st.decodeTokens / (st.decodeMs / 1000)
}
} catch { }
try {
const meter = ctx.get('tokenMeter')
const m = meter?.measure?.(session)
if (m) {
// 第一次把真实字段名记进状态文件,便于在用户机器上核对映射
if (!meterKeysLogged) { meterKeysLogged = true; log('tokenMeter keys: ' + Object.keys(m).join(',')) }
out.tokens = m.totalTokens ?? m.surfaceTokens ?? null
const u = m.usage || m.providerUsage || m.usageBuckets
if (u) {
const read = u.cacheRead ?? u.cache_read ?? u.cacheReadTokens ?? 0
const input = u.input ?? u.inputTokens ?? 0
const denom = input + read
if (denom > 0) out.cacheHit = read / denom
}
let capacity = contextWindows.get(id) || modelContext()
const used = m.surfaceTokens ?? m.totalTokens
if (capacity && used) out.contextUsed = Math.min(1, used / capacity)
}
} catch (e) { if (!meterKeysLogged) { meterKeysLogged = true; log('tokenMeter measure failed: ' + String(e?.message || e)) } }
return out
}
/**
* 模型上下文容量。
* 踩坑(实测):`llm.resolveModelInfo()` 在没注册 provider 时**会抛错**,而且它返回
* Promise —— 只 try/catch 同步部分会留下 unhandled rejection,直接让 DSH 进程
* "fatal load failure" 退出(我在隔离实例里把 DSH 弄崩过一次)。
* 所以这里:同步异常、异步 reject、undefined 三种情况全部吞掉,并且只在拿到值时缓存。
*/
let cachedCapacity = null
function modelContext() {
try {
const llm = ctx.get('llm')
const r = llm?.resolveModelInfo?.()
if (r && typeof r.then === 'function') {
r.then((v) => { cachedCapacity = v?.context ?? cachedCapacity }).catch(() => { })
return cachedCapacity
}
if (r?.context) cachedCapacity = r.context
return cachedCapacity
} catch { return cachedCapacity }
}
function snapshot() {
const sessions = (() => { try { return ctx.get('sessions')?.list?.() || [] } catch { return [] } })()
const activeId = pinnedSessionId || lastSessionId
let session = sessions.find((s) => s?.id === activeId) || sessions[sessions.length - 1] || null
const id = session?.id || activeId
return {
ok: true,
at: Date.now(),
sessionId: id || null,
pinned: pinnedSessionId || null,
title: (id && titles.get(id)) || session?.meta?.title || session?.title || null,
messages: (id && rows.get(id)) || [],
metrics: session ? metricsFor(session) : { turns: null, steps: null, tps: null, tokens: null, cacheHit: null, contextUsed: null },
settings: floatCfg,
// 会话列表:浮窗的切换器用它(多会话/多任务并行时挑一个跟随)
sessions: sessions.slice(-20).map((s) => {
const sid = s?.id
const c = counts.get(sid) || {}
return {
id: sid,
title: titles.get(sid) || s?.meta?.title || s?.title || null,
turns: c.turns || 0,
steps: c.steps || 0,
messages: (rows.get(sid) || []).length,
active: sid === id,
}
}),
}
}
// ---------------------------------------------------------------- 路由
let tickCount = 0
const sseClients = new Set()
const push = () => {
if (!sseClients.size) return
const payload = `data: ${JSON.stringify(snapshot())}\n\n`
for (const res of sseClients) { try { res.write(payload) } catch { } }
}
// ---------------------------------------------------------------- 文件桥
// 桌面壳(DSH Desktop)的 HTTP 网关会把未认证请求全部挡掉(实测 6 种请求形态都是 403),
// 而浮窗是独立进程 + file:// 页面,拿不到壳的 auth cookie。所以除了 HTTP/SSE,
// 再开一条纯文件通道:宿主每秒写 snapshot.json,浮窗每秒读;浮窗要发消息就写 outbox.json。
const snapshotFile = path.join(dshHome(), 'float-window', 'snapshot.json')
const outboxFile = path.join(dshHome(), 'float-window', 'outbox.json')
const writeSnapshotFile = () => {
try {
const snap = snapshot()
// 镜像只留最近 40 条,避免文件太大
if (Array.isArray(snap.messages) && snap.messages.length > 40) snap.messages = snap.messages.slice(-40)
fs.mkdirSync(path.dirname(snapshotFile), { recursive: true })
fs.writeFileSync(snapshotFile, JSON.stringify(snap))
} catch { }
}
const tick = setInterval(() => {
push()
writeSnapshotFile()
if (++tickCount % 10 === 0) writeStatus()
}, 1000)
ctx.effect(() => () => clearInterval(tick))
// 浮窗 → 宿主:轮询 outbox.json
let lastOutboxText = ''
const outboxTimer = setInterval(async () => {
try {
if (!fs.existsSync(outboxFile)) return
// 容错:外部写入可能带 UTF-8 BOM
const text = fs.readFileSync(outboxFile, 'utf8').replace(/^\uFEFF/, '')
if (!text || text === lastOutboxText) return
lastOutboxText = text
fs.rmSync(outboxFile, { force: true })
const msg = JSON.parse(text)
log('outbox: ' + short(msg, 140))
if (msg.cmd === 'chat' && msg.text) {
const target = msg.sessionId || lastSessionId
if (!target) { log('outbox chat skipped: no session yet'); return }
const { value, via } = await hostCall('prompt', {
sessionId: target,
mode: 'queue',
content: [{ type: 'text', text: String(msg.text).slice(0, 20000) }],
clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
})
log(`outbox chat -> ${target} via ${via}: ${short(value, 120)}`)
} else if (msg.cmd === 'window' && msg.action) {
await windowAction(msg.action)
} else if (msg.cmd === 'watch') {
// 浮窗切换跟随的会话:'' / null 表示回到"跟随最新"
pinnedSessionId = msg.sessionId ? String(msg.sessionId) : null
if (pinnedSessionId) lastSessionId = pinnedSessionId
log('watch -> ' + (pinnedSessionId || 'latest'))
} else if (msg.cmd === 'session' && msg.action === 'create') {
const { value } = await hostCall('create', {
sessionId: randomUUID(),
...(msg.cwd ? { cwd: String(msg.cwd) } : {}),
...(msg.agentPreset ? { agentPreset: String(msg.agentPreset) } : {}),
})
if (value?.sessionId) lastSessionId = value.sessionId
}
writeSnapshotFile()
} catch (e) { log('outbox failed: ' + short(e?.data || e?.message || e, 180)) }
}, 700)
ctx.effect(() => () => {
clearInterval(outboxTimer)
try { fs.rmSync(snapshotFile, { force: true }) } catch { }
try { fs.rmSync(outboxFile, { force: true }) } catch { }
})
const handler = async (req, res) => {
const url = new URL(req.url || '/', 'http://127.0.0.1')
const cors = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'content-type',
'Access-Control-Allow-Methods': 'GET,POST,OPTIONS',
}
if (req.method === 'OPTIONS') { res.writeHead(204, cors); res.end(); return }
if (url.pathname === `${ROUTE}/events`) {
res.writeHead(200, { ...cors, 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-cache', Connection: 'keep-alive' })
res.write(': connected\n\n')
res.write(`data: ${JSON.stringify(snapshot())}\n\n`)
sseClients.add(res)
req.on('close', () => sseClients.delete(res))
return
}
if (url.pathname === `${ROUTE}/state`) {
res.writeHead(200, { ...cors, 'Content-Type': 'application/json; charset=utf-8' })
res.end(JSON.stringify(snapshot()))
return
}
if (url.pathname === `${ROUTE}/health`) {
res.writeHead(200, { ...cors, 'Content-Type': 'application/json; charset=utf-8' })
res.end(JSON.stringify({ ok: true, version: '0.1.0', shell: shellPath(), tool: status.tool, settings: status.settings }))
return
}
// 悬浮窗聊天框 -> 会话。走宿主自己的网关服务 ctx.apiProxy(和浏览器 UI 同一条路),
// 不经过 /api 的浏览器信任围栏,所以 file:// 的浮窗页面也能发消息。
if (url.pathname === `${ROUTE}/chat` && req.method === 'POST') {
let text = ''
let sessionId = null
try {
const chunks = []
for await (const c of req) chunks.push(c)
const body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}')
text = String(body.text || '').slice(0, 20000)
sessionId = body.sessionId || null
} catch (e) {
res.writeHead(400, { ...cors, 'Content-Type': 'application/json; charset=utf-8' })
res.end(JSON.stringify({ ok: false, error: 'bad-body: ' + String(e?.message || e) }))
return
}
if (!text) {
res.writeHead(400, { ...cors, 'Content-Type': 'application/json; charset=utf-8' })
res.end(JSON.stringify({ ok: false, error: 'empty text' }))
return
}
const target = sessionId || lastSessionId
try {
if (!target) throw new Error('no session yet')
const { value, via } = await hostCall('prompt', {
sessionId: target,
mode: 'queue',
content: [{ type: 'text', text }],
clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
})
log(`chat -> session ${target} via ${via}: ${short(value, 160)}`)
res.writeHead(200, { ...cors, 'Content-Type': 'application/json; charset=utf-8' })
res.end(JSON.stringify({ ok: true, sessionId: target, result: value, via }))
} catch (e) {
const detail = e?.data || e?.message || e
log('chat failed: ' + short(detail, 200))
res.writeHead(200, { ...cors, 'Content-Type': 'application/json; charset=utf-8' })
res.end(JSON.stringify({ ok: false, error: short(detail, 300) }))
}
return
}
// 会话控制:列表 / 新建 / 打断(浮窗顶部的"新会话"和"停止"将来用这里)
if (url.pathname === `${ROUTE}/session` && req.method === 'POST') {
let body = {}
try {
const chunks = []
for await (const c of req) chunks.push(c)
body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}')
} catch { }
const json = (obj) => { res.writeHead(200, { ...cors, 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(obj)) }
try {
if (body.action === 'create') {
const { value, via } = await hostCall('create', {
sessionId: body.sessionId ? String(body.sessionId) : randomUUID(),
...(body.cwd ? { cwd: String(body.cwd) } : {}),
...(body.workspaceId ? { workspaceId: body.workspaceId } : {}),
...(body.agentPreset ? { agentPreset: String(body.agentPreset) } : {}),
})
const sessionId = value?.sessionId || null
if (sessionId) lastSessionId = sessionId
log(`session created via ${via}: ` + short(value, 160))
json({ ok: true, sessionId, via, result: value })
} else if (body.action === 'cancel') {
const { value, via } = await hostCall('cancel', { sessionId: body.sessionId || lastSessionId })
json({ ok: true, via, result: value })
} else {
json({ ok: true, sessions: snapshot().sessions, current: lastSessionId })
}
} catch (e) {
log('session route failed: ' + short(e?.data || e?.message || e, 200))
if (e?.stack) log('stack: ' + String(e.stack).split('\n').slice(0, 4).join(' | '))
json({ ok: false, error: short(e?.data || e?.message || e, 300) })
}
return
}
// 浮窗动作(Agent 工具走同一段逻辑):open/close/pin/sizeLock/topmost/fullscreen/hide/show/status
if (url.pathname === `${ROUTE}/window` && req.method === 'POST') {
let body = {}
try {
const chunks = []
for await (const c of req) chunks.push(c)
body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}')
} catch { }
const r = await windowAction(body.action)
res.writeHead(200, { ...cors, 'Content-Type': 'application/json; charset=utf-8' })
res.end(JSON.stringify({ ...r, alive: shellAlive() }))
return
}
res.writeHead(404, { ...cors, 'Content-Type': 'text/plain; charset=utf-8' })
res.end('dsh-float-window: not found')
}
ctx.effect(() => ctx.webServer.register({ kind: 'prefix', path: ROUTE, handler }))
// ---------------------------------------------------------------- 壳进程
const stateDir = path.join(dshHome(), 'float-window')
const cmdFile = path.join(stateDir, 'command.json')
const aliveFile = path.join(stateDir, 'alive.json')
function shellPath() {
return path.join(REPO, 'shell', 'run-float.cmd')
}
/** 浮窗动作的唯一入口:Agent 工具和 /window 路由都走这里 */
async function windowAction(action) {
const a = String(action || 'open')
if (a === 'status') {
const s = snapshot()
return { ok: true, text: `alive=${shellAlive()} session=${s.sessionId || 'none'} messages=${s.messages.length} turns=${s.metrics.turns ?? '?'} steps=${s.metrics.steps ?? '?'}` }
}
if (a === 'hide') broadcast({ type: 'cmd', cmd: 'hide' })
const r = await controlFloat(a)
return { ok: !!r.ok, text: r.ok ? `${a} ok (${r.via})` : `${a} failed: ${r.error}`, via: r.via, error: r.error }
}
/** 壳每 700ms 写一次心跳,据此判断浮窗是否还活着 */
function shellAlive() {
try { return Date.now() - fs.statSync(aliveFile).mtimeMs < 8000 } catch { return false }
}
async function spawnShell() {
// 直接起 powershell(隐藏窗口)。**不要**走 `cmd /c start`:那会弹出一个黑色控制台窗口
// (用户已经看到过:标题 C:\WINDOWS\system32\cmd.exe,且批处理里的中文被 GBK 解析成乱码报错)。
const ps1 = path.join(REPO, 'shell', 'float-window.ps1')
if (!fs.existsSync(ps1)) return { ok: false, error: `shell script not found: ${ps1}` }
try {
const { spawn } = await import('node:child_process')
spawn('powershell.exe',
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-WindowStyle', 'Hidden', '-File', ps1],
{ detached: true, stdio: 'ignore', windowsHide: true }).unref()
return { ok: true, via: 'spawn powershell (hidden)' }
} catch (e) { return { ok: false, error: String(e?.message || e) } }
}
/** 浮窗已经在跑就投递命令,否则按需拉起 */
async function controlFloat(action) {
if (action === 'open' && !shellAlive()) return spawnShell()
if (action === 'close' && !shellAlive()) return { ok: true, via: 'already closed' }
try {
fs.mkdirSync(stateDir, { recursive: true })
fs.writeFileSync(cmdFile, JSON.stringify({ cmd: action, at: new Date().toISOString() }))
return { ok: true, via: 'command file' }
} catch (e) { return { ok: false, error: String(e?.message || e) } }
}
// ---------------------------------------------------------------- Agent 工具
// 踩坑(真机事故 ×2):
// a) 0.1.7 的 tools 服务要求模型侧看到的是 **JSON Schema**,参数必须经 `defineTool()`
// (内部 parameterSchemaSpecToJsonSchema)转换。直接塞 DSL 对象会让 provider 收到
// `parameters.type = null`,于是**每一轮请求都被拒**:
// Invalid schema for function 'float_window': schema must be a JSON Schema of 'type: "object"'
// 所以:解析不到 defineTool 就**宁可不注册**;注册前自检 type === 'object'。
// b) 注册必须发生在 apply() 的**同步**阶段。放进 async 续体会拿到
// "cannot create effect on inactive context"(cordis 的 ctx 在 apply 返回后失效)。
status.shell = shellPath()
writeStatus()
try {
const tools = ctx.get('tools')
const defineTool = loadHostModule('@deepseek-ai/dsh-tools')?.defineTool
if (floatCfg.tool === false) {
log('tool disabled by config (tool: false)')
} else if (!tools?.register) {
log('tools service not available; agent trigger disabled')
} else if (typeof defineTool !== 'function') {
log('defineTool unresolvable; tool NOT registered (avoid emitting an invalid schema)')
} else {
const tool = defineTool({
name: 'float_window',
description: '控制 DSH 悬浮窗(始终置顶的会话镜像小窗):open 打开/聚焦,close 关闭,pin 切换固定位置,fullscreen 切换全屏,hide 临时隐藏(抓屏前用)。',
parameters: {
action: { type: 'string', required: true, description: 'open | close | pin | fullscreen | hide | status' },
},
output: { schema: { type: 'string' }, render: (_a, v) => [{ type: 'text', text: v }] },
async execute(args) {
const r = await windowAction(args?.action)
return `float-window: ${r.text}`
},
})
const schema = tool?.parameters
if (!schema || schema.type !== 'object' || !schema.properties) {
status.toolSchema = schema ?? null
log('tool schema self-check FAILED; not registering: ' + short(schema, 200))
} else {
tools.register(tool)
status.tool = true
status.toolSchema = schema
log('tool float_window registered; schema: ' + short(schema, 240))
}
}
} catch (e) { log('tool registration failed:', String(e?.message || e)) }
writeStatus()
const broadcast = (msg) => {
const payload = `data: ${JSON.stringify(msg)}\n\n`
for (const res of sseClients) { try { res.write(payload) } catch { } }
}
const pushHidden = () => broadcast({ type: 'cmd', cmd: 'hide' })
// ---------------------------------------------------------------- 页面按钮(index.html 注入)
// 宿主侧往 index.html 里塞一个悬浮按钮:不依赖客户端插件 roster,web/桌面壳都生效。
// 客户端半(lib/client.js)如果注册成功,会把按钮移除,避免出现两个入口。
if (floatCfg.button !== false) {
try {
const pill = `
`
ctx.effect(() => ctx.webServer.tapIndex((html) => (typeof html === 'string' && html.includes('