dsh-remote-plugin 0.7.0-rc.2 → 0.7.0-rc.3

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.
Binary file
package/gateway.cjs CHANGED
@@ -85,6 +85,7 @@ const TOKEN_FILE = process.env.TOKEN_FILE || path.join(os.homedir(), '.dsh-remot
85
85
  const NOTES_FILE = process.env.DSH_REMOTE_NOTES || path.join(os.homedir(), '.dsh-remote', 'device-notes.json')
86
86
  const DEVICE_KEYS_FILE = process.env.DSH_REMOTE_DEVICE_KEYS || path.join(os.homedir(), '.dsh-remote', 'device-keys.json')
87
87
  const WORKBENCH_FILE = process.env.DSH_REMOTE_WORKBENCH || path.join(os.homedir(), '.dsh-remote', 'workbench.json')
88
+ const HANDOFF_FILE = process.env.DSH_REMOTE_HANDOFF || path.join(os.homedir(), '.dsh-remote', 'handoff.json')
88
89
  const STARTED_AT = Date.now()
89
90
  const DSH_SERVICE = String(process.env.DSH_REMOTE_DSH_SERVICE || 'dsh-web').trim()
90
91
  const SYSTEMCTL = String(process.env.DSH_REMOTE_SYSTEMCTL || 'systemctl').trim() || 'systemctl'
@@ -1479,7 +1480,10 @@ async function detectUpstreamApiFlavor(force = false) {
1479
1480
  }
1480
1481
  } catch (error) {
1481
1482
  upstreamApiFlavorCheckedAt = Date.now()
1482
- if (upstreamApiFlavor === 'unknown') upstreamApiFlavor = 'legacy'
1483
+ // 网络级失败(上游未启动/cookie 未就绪)不改变 flavor:
1484
+ // 把 unknown 定格成 legacy 会让新版 DSH 被永久当旧服务器探测,
1485
+ // events 双流从此连不上(websocket error 循环)。保持 unknown,
1486
+ // 下一轮 recheck 再探;已有明确 flavor 的也不因瞬断回退。
1483
1487
  recordCompatibility('protocol-probe-failed', { detail: error?.message || error })
1484
1488
  } finally {
1485
1489
  upstreamApiFlavorProbe = null
@@ -1975,6 +1979,11 @@ function legacyPush(kind, payload, rpcId = crypto.randomUUID()) {
1975
1979
 
1976
1980
  function openModernSessionStream(ws, sessionId) {
1977
1981
  if (!sessionId || ws.readyState !== 1) return
1982
+ // subagent 来源的会话在 DSH 侧只接受带 durable parent address 的 follow,
1983
+ // 直接按 session 地址 open 会被拒(session/agent-busy),错误帧还会污染事件流;
1984
+ // 这些会话的投影变化由 control/workspaces 基线推送覆盖,无需单独 follow。
1985
+ const summary = modernState.sessions.get(sessionId)
1986
+ if (summary?.origin === 'subagent') return
1978
1987
  const streamId = 'session:' + sessionId
1979
1988
  ws.send(JSON.stringify({
1980
1989
  type: 'open', streamId, endpoint: 'session/follow',
@@ -3971,6 +3980,127 @@ function workbenchPathInfo(rawPath) {
3971
3980
  return { path: checked.abs }
3972
3981
  }
3973
3982
 
3983
+ /* ---------- /handoff 跨端接续指针 ----------
3984
+ * 单条记录:最近一次"正在查看的会话"。双端客户端离开会话时 PUT,
3985
+ * 进入会话列表时 GET,发现是另一台设备写的就展示"在 xx 上打开过"接续卡片。
3986
+ * 只存指针(sessionId + 设备名),不存消息内容——消息仍由 DSH 实时通道下发。
3987
+ * 支持多服务器共用一个网关:key 是上游会话所属服务器没有区分需求,
3988
+ * 这里按"该 token 能看到的同一个 DSH"即单工作台语义,只存一份。
3989
+ */
3990
+ const HANDOFF_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
3991
+ let handoffMemory = null
3992
+
3993
+ function handoffLoad() {
3994
+ if (handoffMemory) return handoffMemory
3995
+ try {
3996
+ const raw = JSON.parse(fs.readFileSync(HANDOFF_FILE, 'utf8'))
3997
+ if (raw && typeof raw === 'object' && typeof raw.sessionId === 'string' && raw.sessionId) {
3998
+ handoffMemory = {
3999
+ sessionId: String(raw.sessionId).slice(0, 128),
4000
+ title: String(raw.title || '').slice(0, 200),
4001
+ device: String(raw.device || '').slice(0, 80),
4002
+ clientId: String(raw.clientId || '').slice(0, 96),
4003
+ at: Number(raw.at) || 0
4004
+ }
4005
+ }
4006
+ } catch {}
4007
+ return handoffMemory
4008
+ }
4009
+
4010
+ function handoffSave(record) {
4011
+ handoffMemory = record
4012
+ try {
4013
+ fs.mkdirSync(path.dirname(HANDOFF_FILE), { recursive: true })
4014
+ fs.writeFileSync(HANDOFF_FILE, JSON.stringify(record, null, 2) + '\n')
4015
+ } catch {}
4016
+ }
4017
+
4018
+ function handoffFresh() {
4019
+ const rec = handoffLoad()
4020
+ if (!rec) return null
4021
+ if (!rec.at || Date.now() - rec.at > HANDOFF_MAX_AGE_MS) return null
4022
+ return rec
4023
+ }
4024
+
4025
+ function serveHandoff(req, res, url) {
4026
+ cors(res)
4027
+ if (req.method === 'OPTIONS') {
4028
+ res.writeHead(204)
4029
+ res.end()
4030
+ return
4031
+ }
4032
+ if (req.method !== 'GET' && req.method !== 'PUT' && req.method !== 'DELETE') {
4033
+ res.writeHead(405, { allow: 'GET, PUT, DELETE' })
4034
+ res.end()
4035
+ return
4036
+ }
4037
+ if (!authorized(req, url)) {
4038
+ authFailures++
4039
+ touchDevice(req, { failedAuth: true })
4040
+ res.writeHead(401, { 'content-type': 'application/json; charset=utf-8' })
4041
+ res.end(JSON.stringify({ error: 'unauthorized' }))
4042
+ return
4043
+ }
4044
+ touchDevice(req)
4045
+
4046
+ if (req.method === 'GET') {
4047
+ const rec = handoffFresh()
4048
+ fsJson(res, 200, { ok: true, handoff: rec })
4049
+ return
4050
+ }
4051
+
4052
+ if (req.method === 'DELETE') {
4053
+ handoffMemory = null
4054
+ try { fs.rmSync(HANDOFF_FILE, { force: true }) } catch {}
4055
+ fsJson(res, 200, { ok: true })
4056
+ return
4057
+ }
4058
+
4059
+ // PUT:读 JSON 体,字段白名单 + 截断,4KB 上限
4060
+ let body = ''
4061
+ let done = false
4062
+ req.on('data', chunk => {
4063
+ if (done) return
4064
+ body += chunk
4065
+ if (Buffer.byteLength(body) > 4096) {
4066
+ done = true
4067
+ fsJson(res, 413, { error: 'payload too large' })
4068
+ req.destroy()
4069
+ }
4070
+ })
4071
+ req.on('end', () => {
4072
+ if (done || res.headersSent) return
4073
+ done = true
4074
+ let payload
4075
+ try {
4076
+ payload = JSON.parse(body || '{}')
4077
+ } catch {
4078
+ fsJson(res, 400, { error: 'invalid json' })
4079
+ return
4080
+ }
4081
+ // JSON 允许 null/数组/标量:后续读 payload.sessionId 会在 end 回调里抛
4082
+ // TypeError(外层 try/catch 接不住,请求悬挂无响应,2026-09-20 评审复现)。
4083
+ // 先校验为普通对象,不合规直接 400。
4084
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
4085
+ fsJson(res, 400, { error: 'object body required' })
4086
+ return
4087
+ }
4088
+ const sessionId = typeof payload.sessionId === 'string' ? payload.sessionId.trim() : ''
4089
+ if (!sessionId) {
4090
+ fsJson(res, 400, { error: 'sessionId required' })
4091
+ return
4092
+ }
4093
+ handoffSave({
4094
+ sessionId: sessionId.slice(0, 128),
4095
+ title: String(payload.title || '').slice(0, 200),
4096
+ device: String(payload.device || '').slice(0, 80),
4097
+ clientId: String(payload.clientId || '').slice(0, 96),
4098
+ at: Date.now()
4099
+ })
4100
+ fsJson(res, 200, { ok: true })
4101
+ })
4102
+ }
4103
+
3974
4104
  function loadWorkbench() {
3975
4105
  try {
3976
4106
  const raw = JSON.parse(fs.readFileSync(WORKBENCH_FILE, 'utf8'))
@@ -4369,6 +4499,7 @@ const server = http.createServer(async (req, res) => {
4369
4499
  if (url.pathname === '/workbench' || url.pathname.startsWith('/workbench/')) return serveWorkbench(req, res, url)
4370
4500
  if (url.pathname === '/diagnostics') return serveDiagnostics(req, res, url)
4371
4501
  if (url.pathname === '/feedback') return serveFeedback(req, res, url)
4502
+ if (url.pathname === '/handoff') return serveHandoff(req, res, url)
4372
4503
  if (url.pathname.startsWith('/admin/api')) return serveAdminApi(req, res, url)
4373
4504
  if (url.pathname.startsWith('/stats')) return serveStats(req, res, url)
4374
4505
  if (url.pathname === '/api/ws-ticket') return serveWsTicket(req, res, url)
@@ -4417,6 +4548,41 @@ function wsPingFrame(masked) {
4417
4548
  return Buffer.concat([Buffer.from([0x89, 0x80]), mask])
4418
4549
  }
4419
4550
 
4551
+ /**
4552
+ * 从缓冲头部解析一个完整客户端帧(RFC6455 §5.2)。
4553
+ * 返回 null 表示数据不够、需要等下一个 chunk;解析成功返回
4554
+ * { fin, opcode, payload(已解掩码), total(帧总长) }。
4555
+ * 客户端帧必须带掩码位(§5.1),无掩码直接按协议错误返回 null 并由调用方清缓冲。
4556
+ * 只支持 ≤125 载荷的控制帧上限约束交由调用方(Ping 载荷协议上限 125);
4557
+ * 长度用 126/127 扩展格式完整解析,保证数据帧边界正确跳过。
4558
+ */
4559
+ function wsParseClientFrame(buf) {
4560
+ if (buf.length < 2) return null
4561
+ const fin = (buf[0] & 0x80) !== 0
4562
+ const opcode = buf[0] & 0x0f
4563
+ const masked = (buf[1] & 0x80) !== 0
4564
+ let len = buf[1] & 0x7f
4565
+ let off = 2
4566
+ if (len === 126) {
4567
+ if (buf.length < 4) return null
4568
+ len = buf.readUInt16BE(2)
4569
+ off = 4
4570
+ } else if (len === 127) {
4571
+ if (buf.length < 10) return null
4572
+ const big = buf.readBigUInt64BE(2)
4573
+ if (big > BigInt(1 << 20)) return 'oversize'
4574
+ len = Number(big)
4575
+ off = 10
4576
+ }
4577
+ if (!masked) return 'unmasked'
4578
+ if (buf.length < off + 4 + len) return null
4579
+ const mask = buf.subarray(off, off + 4)
4580
+ const payload = Buffer.alloc(len)
4581
+ const src = buf.subarray(off + 4, off + 4 + len)
4582
+ for (let i = 0; i < len; i++) payload[i] = src[i] ^ mask[i & 3]
4583
+ return { fin, opcode, payload, total: off + 4 + len }
4584
+ }
4585
+
4420
4586
  /**
4421
4587
  * 原始 TCP 透传也要维护 WebSocket 控制帧活性:
4422
4588
  * - 浏览器侧收到网关的未掩码 Ping 后会自动回 Pong;
@@ -4521,6 +4687,41 @@ function acceptCollectorClient(req, socket, head, kind, device) {
4521
4687
  socket.setNoDelay(true)
4522
4688
  collectorClients[kind].add(socket)
4523
4689
  eventCollectorState[kind].clients = collectorClients[kind].size
4690
+ // 客户端(鸿蒙 lws 等严格 RFC 实现)会发掩码 Ping 并等待 Pong;
4691
+ // 网关必须按 RFC6455 回 Pong(0x8A),且服务端→客户端方向不带掩码位
4692
+ // (RFC6455 §5.1:客户端帧必须掩码,服务端帧必须不掩码,违规即断链)。
4693
+ // TCP 不保证帧边界与 data 事件对齐,逐字节扫描当前 chunk 会把跨包帧、
4694
+ // 其他帧的掩码/载荷误认成帧头(2026-09-20 评审复现:掩码后拆包回出错误
4695
+ // Pong 8a03010203)。这里按 RFC6455 组帧:维护 per-socket 缓冲,帧头
4696
+ // (含 126/127 扩展长度)与载荷凑齐才应答,整帧消费,控制帧间数据帧安全跳过。
4697
+ let rx = Buffer.alloc(0)
4698
+ socket.on('data', (chunk) => {
4699
+ rx = rx.length ? Buffer.concat([rx, chunk]) : chunk
4700
+ while (true) {
4701
+ let frame
4702
+ try {
4703
+ frame = wsParseClientFrame(rx)
4704
+ } catch {
4705
+ rx = Buffer.alloc(0)
4706
+ break
4707
+ }
4708
+ if (!frame) break
4709
+ if (typeof frame === 'string') { rx = Buffer.alloc(0); break }
4710
+ rx = rx.subarray(frame.total)
4711
+ if (frame.payload.length > 125) continue
4712
+ if (frame.fin === false) continue
4713
+ // 仅应答 Ping(RFC6455 §5.5 控制帧载荷上限 125);Pong(0xA)消费后丢弃,
4714
+ // 其余 opcode(含数据帧/分片)跳过
4715
+ if (frame.opcode === 0x9) {
4716
+ const pong = Buffer.alloc(2 + frame.payload.length)
4717
+ pong[0] = 0x8a
4718
+ pong[1] = frame.payload.length
4719
+ frame.payload.copy(pong, 2)
4720
+ try { socket.write(pong) } catch {}
4721
+ }
4722
+ }
4723
+ if (rx.length > 1 << 20) rx = Buffer.alloc(0)
4724
+ })
4524
4725
  for (const raw of collectorReplay[kind].values()) {
4525
4726
  if (socket.destroyed || !socket.writable) break
4526
4727
  try { socket.write(encodeWsText(raw)) } catch { break }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-plugin",
3
- "version": "0.7.0-rc.2",
3
+ "version": "0.7.0-rc.3",
4
4
  "description": "DSH Remote 官方 bundle 插件:DSH 左侧原生边栏入口 + 右侧抽屉内嵌管理控制台;内置网关随 DSH 自动启停(systemd 独立单元),抽屉直显令牌与设备监控;网关提供 /fs/* 文件传输端点(列表/断点下载/上传),配合 Android App 远程操控会话/审批/提问/goal 与文件互传(多服务器测速切换、聊天记录离线缓存)。",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
package/public/app.js CHANGED
@@ -132,6 +132,7 @@ const CUSTOM_SELECT_TITLES = {
132
132
  'session-sort': 'sessions.sortLabel',
133
133
  'fs-workspace': 'workspace.select',
134
134
  'mobile-enter-action': 'settings.mobileEnterTitle',
135
+ 'busy-send-mode': 'settings.busySendTitle',
135
136
  'bg-interval': 'settings.bgIntervalTitle',
136
137
  'new-session-workspace': 'workspace.select'
137
138
  }
@@ -606,7 +607,9 @@ async function rpc(method, payload = {}, timeoutMs = 45000) {
606
607
  if (!full?.result) throw new Error(t('err.badResponse'))
607
608
  if (!full.result.ok) {
608
609
  const err = full.result.error || {}
609
- throw new Error(err.message || t('err.dshError'))
610
+ const error = new Error(err.message || t('err.dshError'))
611
+ error.rpcRejected = true
612
+ throw error
610
613
  }
611
614
  return full.result.value
612
615
  }
@@ -2416,6 +2419,7 @@ function renderSessionSub() {
2416
2419
 
2417
2420
  /** 顶栏状态: 运行中=蓝色流动渐变, 中断/出错=橙红渐变, 空闲=原样式 */
2418
2421
  function updateSessionStatus() {
2422
+ renderComposerSend()
2419
2423
  const s = state.byId.get(state.current)
2420
2424
  const head = $('session-head')
2421
2425
  if (!head) return
@@ -3248,33 +3252,153 @@ async function sendSessionText(text) {
3248
3252
  return sendSessionContent(text, [])
3249
3253
  }
3250
3254
 
3251
- async function sendSessionContent(text, images) {
3255
+ let composerSending = false
3256
+ const composerSendFeedback = new Map()
3257
+ const steeringInFlight = new Set()
3258
+ function composerContext() { return `${state.server}\0${state.current}` }
3259
+ function steerSendingEnabled() { return LS.get('steerSendingEnabled', '0') === '1' }
3260
+ function composerIsBusy() {
3261
+ return !!state.byId.get(state.current)?.running || (state.queues[state.current] || []).some(item => item.placement === 'queued')
3262
+ }
3263
+ function composerSendMode(alternate = false) {
3264
+ if (!steerSendingEnabled() || !composerIsBusy()) return 'queue'
3265
+ const preferred = LS.get('busySendMode', 'queue') === 'steer' ? 'steer' : 'queue'
3266
+ return alternate ? (preferred === 'queue' ? 'steer' : 'queue') : preferred
3267
+ }
3268
+ function renderComposerSend() {
3269
+ const enabled = steerSendingEnabled() && composerIsBusy()
3270
+ const mode = composerSendMode()
3271
+ for (const id of ['btn-send', 'btn-fs-send']) {
3272
+ const button = $(id)
3273
+ if (!button) continue
3274
+ if (composerSending || !button.classList.contains('send-hold-ready')) button.textContent = t(composerSending ? 'send.submitting' : enabled ? `send.${mode}Button` : 'composer.send')
3275
+ button.disabled = composerSending
3276
+ button.classList.toggle('steer-mode', enabled && mode === 'steer')
3277
+ button.title = t(enabled ? 'send.holdHint' : 'composer.send')
3278
+ button.setAttribute('aria-label', button.textContent)
3279
+ }
3280
+ const feedback = composerSendFeedback.get(composerContext())
3281
+ const node = $('composer-send-status')
3282
+ if (node) {
3283
+ node.textContent = feedback ? t(feedback.key, { msg: feedback.message || '' }) : enabled ? t('send.holdHint') : ''
3284
+ node.classList.toggle('hidden', !node.textContent)
3285
+ node.dataset.phase = feedback?.phase || 'hint'
3286
+ }
3287
+ }
3288
+ function setComposerSendFeedback(context, key, phase, message = '') {
3289
+ composerSendFeedback.set(context, { key, phase, message })
3290
+ if (composerSendFeedback.size > 100) composerSendFeedback.delete(composerSendFeedback.keys().next().value)
3291
+ renderComposerSend()
3292
+ }
3293
+ // No preflight or automatic retry: an ambiguous timeout may already have delivered the message.
3294
+ async function submitSteer(method, payload, context) {
3295
+ if (steeringInFlight.has(context)) {
3296
+ if (composerContext() === context) toast(t('send.steerPending'), 'err')
3297
+ return null
3298
+ }
3299
+ steeringInFlight.add(context)
3300
+ setComposerSendFeedback(context, 'send.steerPending', 'pending')
3301
+ const slow = setTimeout(() => setComposerSendFeedback(context, 'send.steerSlow', 'pending'), 3000)
3302
+ try {
3303
+ const result = await rpc(method, payload)
3304
+ if (!result?.accepted) {
3305
+ setComposerSendFeedback(context, 'send.steerUnconfirmed', 'unknown')
3306
+ return result
3307
+ }
3308
+ setComposerSendFeedback(context, 'send.steerAccepted', 'accepted')
3309
+ if (composerContext() === context) toast(t('send.steerAccepted'), 'ok')
3310
+ return result
3311
+ } catch (error) {
3312
+ setComposerSendFeedback(context, error.rpcRejected || error.message === 'AUTH' ? 'send.steerRejected' : 'send.steerUnconfirmed', error.rpcRejected ? 'error' : 'unknown', error.message)
3313
+ if (error.message === 'AUTH' && composerContext() === context) authFailure()
3314
+ return null
3315
+ } finally { clearTimeout(slow); steeringInFlight.delete(context) }
3316
+ }
3317
+
3318
+ function bindComposerSend(button) {
3319
+ let press = null
3320
+ let suppressClickUntil = 0
3321
+ const cancel = () => {
3322
+ if (!press) return
3323
+ clearTimeout(press.timer)
3324
+ press = null
3325
+ suppressClickUntil = Date.now() + 1000
3326
+ button.classList.remove('send-hold-ready')
3327
+ renderComposerSend()
3328
+ }
3329
+ button.addEventListener('pointerdown', event => {
3330
+ if (event.button !== 0 || event.isPrimary === false || composerSending || !steerSendingEnabled() || !composerIsBusy()) return
3331
+ cancel()
3332
+ suppressClickUntil = 0
3333
+ press = { id: event.pointerId, x: event.clientX, y: event.clientY, context: composerContext(), mode: composerSendMode(), alternate: composerSendMode(true), ready: false }
3334
+ press.timer = setTimeout(() => {
3335
+ if (!press || composerSending || composerContext() !== press.context) { cancel(); return }
3336
+ press.ready = true
3337
+ button.classList.add('send-hold-ready')
3338
+ button.textContent = t(press.alternate === 'steer' ? 'send.releaseSteer' : 'send.releaseQueue')
3339
+ }, 450)
3340
+ button.setPointerCapture?.(event.pointerId)
3341
+ })
3342
+ button.addEventListener('pointermove', event => {
3343
+ if (press && Math.hypot(event.clientX - press.x, event.clientY - press.y) > 12) cancel()
3344
+ })
3345
+ button.addEventListener('pointerup', event => {
3346
+ if (!press || event.pointerId !== press.id) return
3347
+ const intent = press
3348
+ cancel()
3349
+ event.preventDefault()
3350
+ if (intent.context === composerContext() && steerSendingEnabled()) void sendMessage(intent.ready ? intent.alternate : intent.mode)
3351
+ })
3352
+ for (const name of ['pointercancel', 'lostpointercapture', 'blur']) button.addEventListener(name, cancel)
3353
+ button.addEventListener('contextmenu', event => { if (press || Date.now() < suppressClickUntil) event.preventDefault() })
3354
+ button.addEventListener('keydown', () => { cancel(); suppressClickUntil = 0 })
3355
+ button.addEventListener('click', () => { if (Date.now() >= suppressClickUntil) void sendMessage() })
3356
+ }
3357
+
3358
+ async function sendSessionContent(text, images, requestedMode = 'queue') {
3252
3359
  const clean = String(text || '').trim()
3253
- if ((!clean && !images.length) || !state.current) return false
3360
+ if ((!clean && !images.length) || !state.current || composerSending) return false
3254
3361
  const sessionId = state.current
3255
- if (images.length === 0 && clean && await runSlashCommand(clean)) {
3256
- state.sessionActivity.add(sessionId)
3257
- return true
3362
+ const context = composerContext()
3363
+ const mode = requestedMode === 'steer' && steerSendingEnabled() && composerIsBusy() ? 'steer' : 'queue'
3364
+ if (mode === 'steer' && clean.startsWith('/')) {
3365
+ setComposerSendFeedback(context, 'send.steerSlash', 'error')
3366
+ return false
3258
3367
  }
3368
+ composerSending = true
3259
3369
  const buttons = [$('btn-send'), $('btn-fs-send')].filter(Boolean)
3260
3370
  buttons.forEach(button => { button.disabled = true })
3261
3371
  state.pendingPrompts.add(sessionId)
3262
3372
  try {
3373
+ if (mode === 'steer') setComposerSendFeedback(context, 'send.steerPending', 'pending')
3374
+ else { composerSendFeedback.delete(context); renderComposerSend() }
3375
+ if (images.length === 0 && clean && await runSlashCommand(clean)) {
3376
+ state.sessionActivity.add(sessionId)
3377
+ return true
3378
+ }
3263
3379
  const content = [...await encodeComposerImagesFor(images)]
3264
3380
  if (clean) content.push({ type: 'text', text: clean })
3381
+ if (composerContext() !== context) {
3382
+ setComposerSendFeedback(context, 'send.contextChanged', 'error')
3383
+ return false
3384
+ }
3265
3385
  setSessionRecovery('resuming')
3266
- const v = await safeRpc('session.prompt', {
3386
+ const payload = {
3267
3387
  sessionId,
3268
- mode: 'queue',
3388
+ mode,
3269
3389
  content
3270
- }, t('send.failed'))
3390
+ }
3391
+ const v = mode === 'steer'
3392
+ ? await submitSteer('session.prompt', payload, context)
3393
+ : await safeRpc('session.prompt', payload, t('send.failed'))
3394
+ if (composerContext() !== context) return !!v?.accepted
3271
3395
  if (v?.accepted) {
3272
3396
  state.sessionActivity.add(sessionId)
3273
- if (state.current === sessionId) {
3397
+ if (composerContext() === context) {
3274
3398
  setSessionRecovery('ready')
3275
3399
  noteSessionTurnTime(sessionId, Date.now())
3276
3400
  renderSessions()
3277
- toast(images.length ? t('send.imageSent') : (clean.startsWith('/') ? t('send.commandSent') : t('send.sent')), 'ok')
3401
+ if (mode !== 'steer') toast(images.length ? t('send.imageSent') : (clean.startsWith('/') ? t('send.commandSent') : t('send.sent')), 'ok')
3278
3402
  }
3279
3403
  return true
3280
3404
  }
@@ -3286,14 +3410,17 @@ async function sendSessionContent(text, images) {
3286
3410
  if (state.current === sessionId) setSessionRecovery('error')
3287
3411
  return false
3288
3412
  } catch (e) {
3289
- if (state.current === sessionId) {
3413
+ if (mode === 'steer') setComposerSendFeedback(context, 'send.steerRejected', 'error', e?.message || String(e))
3414
+ if (composerContext() === context) {
3290
3415
  setSessionRecovery('error', e?.message)
3291
3416
  toast(t('composer.imageReadFailed', { msg: e?.message || e }), 'err')
3292
3417
  }
3293
3418
  return false
3294
3419
  } finally {
3295
3420
  state.pendingPrompts.delete(sessionId)
3421
+ composerSending = false
3296
3422
  buttons.forEach(button => { button.disabled = false })
3423
+ renderComposerSend()
3297
3424
  }
3298
3425
  }
3299
3426
 
@@ -3304,16 +3431,18 @@ async function encodeComposerImagesFor(images) {
3304
3431
  })))
3305
3432
  }
3306
3433
 
3307
- async function sendMessage() {
3434
+ async function sendMessage(requestedMode) {
3308
3435
  const input = $('composer-input')
3309
- const text = input.value.trim()
3436
+ const originalText = input.value
3437
+ const text = originalText.trim()
3438
+ const context = composerContext()
3310
3439
  const images = state.composerImages.slice()
3311
3440
  if ((!text && !images.length) || !state.current) return
3312
3441
  if (images.length && text.startsWith('/')) { toast(t('composer.imageSlashUnsupported'), 'err'); return }
3313
- if (await sendSessionContent(text, images)) {
3314
- input.value = ''
3315
- autosize(input)
3316
- clearComposerImages()
3442
+ const mode = typeof requestedMode === 'string' ? requestedMode : composerSendMode()
3443
+ if (await sendSessionContent(text, images, mode) && composerContext() === context) {
3444
+ if (input.value === originalText) { input.value = ''; autosize(input) }
3445
+ if (state.composerImages.length === images.length && images.every((item, i) => item === state.composerImages[i])) clearComposerImages()
3317
3446
  }
3318
3447
  }
3319
3448
 
@@ -3842,20 +3971,21 @@ function queuePreview(item) {
3842
3971
  }
3843
3972
  async function steerQueueItem(itemId) {
3844
3973
  const sessionId = state.current
3974
+ const context = composerContext()
3845
3975
  const key = `${sessionId}:${itemId}`
3846
3976
  const s = state.byId.get(sessionId)
3847
3977
  if (!sessionId || !s?.running || state.queueSteering[key]) return
3848
3978
  state.queueSteering[key] = true
3849
3979
  renderQueue()
3850
3980
  try {
3851
- const v = await safeRpc('session.updateQueue', { sessionId, itemId, action: { kind: 'steer' } }, t('queue.steerFailed', { msg: '' }).replace(/:$/, '').replace(/: $/, ''))
3852
- if (v?.accepted) toast(t('queue.steerSubmitted'), 'ok')
3981
+ await submitSteer('session.updateQueue', { sessionId, itemId, action: { kind: 'steer' } }, context)
3853
3982
  } finally {
3854
3983
  delete state.queueSteering[key]
3855
3984
  renderQueue()
3856
3985
  }
3857
3986
  }
3858
3987
  function renderQueue() {
3988
+ renderComposerSend()
3859
3989
  const s = state.byId.get(state.current)
3860
3990
  if (!s) return
3861
3991
  const items = (state.queues[state.current] || []).filter(item => item?.placement === 'queued')
@@ -6960,8 +7090,8 @@ function bindUi() {
6960
7090
  $('rename-confirm').addEventListener('click', confirmRenameSession)
6961
7091
  $('rename-session-input').addEventListener('keydown', e => { if (e.key === 'Enter' && !e.isComposing) confirmRenameSession() })
6962
7092
  $('modal-rename').addEventListener('click', e => { if (e.target === $('modal-rename')) closeRenameSession() })
6963
- $('btn-send').addEventListener('click', sendMessage)
6964
- $('btn-fs-send').addEventListener('click', sendMessage)
7093
+ bindComposerSend($('btn-send'))
7094
+ bindComposerSend($('btn-fs-send'))
6965
7095
  $('btn-plus').addEventListener('click', toggleComposerMenu)
6966
7096
  $('btn-image').addEventListener('click', toggleComposerImageMenu)
6967
7097
  $('composer-image-menu').addEventListener('click', (e) => {
@@ -7139,11 +7269,23 @@ function bindUi() {
7139
7269
  })
7140
7270
  $('btn-reset').addEventListener('click', () => {
7141
7271
  if (!confirm(t('settings.confirmReset'))) return
7142
- LS.del('token'); LS.del('notify'); LS.del('server'); LS.del('mobileEnterAction'); LS.del(ANNOUNCEMENTS_KEY); LS.del(ANNOUNCEMENT_HISTORY_KEY); LS.del(ANNOUNCEMENT_VOTES_KEY)
7272
+ LS.del('token'); LS.del('notify'); LS.del('server'); LS.del('mobileEnterAction'); LS.del('steerSendingEnabled'); LS.del('busySendMode'); LS.del(ANNOUNCEMENTS_KEY); LS.del(ANNOUNCEMENT_HISTORY_KEY); LS.del(ANNOUNCEMENT_VOTES_KEY)
7143
7273
  if (bgBridge()?.saveBackgroundConfig) saveBgConfig(false)
7144
7274
  location.reload()
7145
7275
  })
7146
7276
  $('mobile-enter-action').value = mobileEnterAction()
7277
+ $('opt-steer-send').checked = steerSendingEnabled()
7278
+ $('busy-send-mode').value = LS.get('busySendMode', 'queue') === 'steer' ? 'steer' : 'queue'
7279
+ $('busy-send-mode').disabled = !steerSendingEnabled()
7280
+ $('opt-steer-send').addEventListener('change', event => {
7281
+ LS.set('steerSendingEnabled', event.target.checked ? '1' : '0')
7282
+ $('busy-send-mode').disabled = !event.target.checked
7283
+ renderComposerSend()
7284
+ })
7285
+ $('busy-send-mode').addEventListener('change', event => {
7286
+ LS.set('busySendMode', event.target.value === 'steer' ? 'steer' : 'queue')
7287
+ renderComposerSend()
7288
+ })
7147
7289
  $('mobile-enter-action').addEventListener('change', (e) => {
7148
7290
  const action = e.target.value === 'send' ? 'send' : 'newline'
7149
7291
  LS.set('mobileEnterAction', action)
package/public/index.html CHANGED
@@ -113,7 +113,7 @@
113
113
  <button id="btn-rename-session" class="icon-btn hidden" data-i18n-title="session.rename" data-i18n-aria="session.rename">✎</button>
114
114
  <button id="btn-archive-session" class="mini-btn hidden" data-i18n="session.archive">归档</button>
115
115
  <button id="btn-stats" class="icon-btn" data-i18n-title="stats.title" data-i18n-aria="stats.title"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 19V9M12 19V5M19 19v-7M3 19h18"/></svg></button>
116
- <button id="btn-fs-send" class="session-send-btn hidden" type="button" data-i18n="composer.send" data-i18n-title="composer.send" data-i18n-aria="composer.send">发送</button>
116
+ <button id="btn-fs-send" class="session-send-btn hidden" type="button">发送</button>
117
117
  <button id="btn-cancel" class="danger-btn hidden" data-i18n="session.stop">停止</button>
118
118
  </div>
119
119
 
@@ -173,6 +173,7 @@
173
173
  <button type="button" class="composer-image-option" data-image-source="CAMERA" role="menuitem"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 8.5h3l1.5-2h7L17 8.5h3v10H4z"/><circle cx="12" cy="13.5" r="3.2"/></svg><span data-i18n="composer.takePhoto">拍照</span></button>
174
174
  <button type="button" class="composer-image-option" data-image-source="PHOTOS" role="menuitem"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3.5" y="4" width="17" height="16" rx="2"/><circle cx="8.5" cy="9" r="1.2"/><path d="m5 17 4.5-4.5 3 3 2-2L19 17"/></svg><span data-i18n="composer.choosePhoto">相册</span></button>
175
175
  </div>
176
+ <div id="composer-send-status" class="composer-send-status hidden" role="status" aria-live="polite" aria-atomic="true"></div>
176
177
  <div id="composer-attachments" class="composer-attachments hidden"></div>
177
178
  <div class="composer">
178
179
  <button id="btn-plus" class="plus-btn" data-i18n-aria="a11y.more" data-i18n-title="a11y.moreTitle">+</button>
@@ -183,7 +184,7 @@
183
184
  </button>
184
185
  </div>
185
186
  <button id="btn-image" class="composer-image-btn" type="button" data-i18n-title="composer.addImage" data-i18n-aria="composer.addImage"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3.5" y="4" width="17" height="16" rx="2"/><circle cx="8.5" cy="9" r="1.2"/><path d="m5 17 4.5-4.5 3 3 2-2L19 17"/></svg></button>
186
- <button id="btn-send" class="send-btn" data-i18n="composer.send">发送</button>
187
+ <button id="btn-send" class="send-btn" type="button">发送</button>
187
188
  </div>
188
189
  <input id="composer-camera-input" class="hidden" type="file" accept="image/*" capture="environment">
189
190
  <input id="composer-gallery-input" class="hidden" type="file" accept="image/*" multiple>
@@ -375,6 +376,17 @@
375
376
  <option value="send" data-i18n="settings.mobileEnterSend">回车发送</option>
376
377
  </select>
377
378
  </div>
379
+ <div class="setting-row">
380
+ <div><div class="setting-name" data-i18n="settings.steerSendTitle">启用长按插队发送</div><div class="setting-desc" data-i18n="settings.steerSendDesc">繁忙时长按发送切换方式,松开发送;移动手指取消。默认关闭。</div></div>
381
+ <label class="switch"><input type="checkbox" id="opt-steer-send" data-i18n-aria="settings.steerSendTitle"><span class="slider"></span></label>
382
+ </div>
383
+ <div class="setting-row">
384
+ <div><div class="setting-name" data-i18n="settings.busySendTitle">繁忙时默认发送方式</div><div class="setting-desc" data-i18n="settings.busySendDesc">单击和回车使用默认方式,长按使用另一种;空闲时正常发送。</div></div>
385
+ <select id="busy-send-mode" class="setting-select" data-i18n-aria="settings.busySendTitle">
386
+ <option value="queue" data-i18n="send.queueButton">排队发送</option>
387
+ <option value="steer" data-i18n="send.steerButton">插队发送</option>
388
+ </select>
389
+ </div>
378
390
  <div class="setting-row">
379
391
  <div><div class="setting-name" data-i18n="presets.title">预设提示词</div><div class="setting-desc" data-i18n="presets.desc">常用提示词,对话界面一键插入(最多 20 条)</div></div>
380
392
  <button id="btn-preset-add" class="mini-btn" data-i18n="presets.add">新增</button>
@@ -907,6 +919,17 @@
907
919
  <script>
908
920
  window.APP_STR = {
909
921
  zh: {
922
+ 'settings.steerSendTitle': '启用长按插队发送', 'settings.steerSendDesc': '繁忙时长按发送切换方式,松开发送;移动手指取消。默认关闭。',
923
+ 'settings.busySendTitle': '繁忙时默认发送方式', 'settings.busySendDesc': '单击和回车使用默认方式,长按使用另一种;空闲时正常发送。',
924
+ 'send.queueButton': '排队发送', 'send.steerButton': '插队发送', 'send.submitting': '提交中…',
925
+ 'send.holdHint': '长按切换发送方式,松开发送;移动取消', 'send.releaseSteer': '松开插队', 'send.releaseQueue': '松开排队',
926
+ 'send.steerPending': '正在提交插队请求,尚未确认;请勿认为当前任务已打断。',
927
+ 'send.steerSlow': '仍在等待 DSH 确认插队,请勿重复发送;当前任务可能仍在执行。',
928
+ 'send.steerAccepted': 'DSH 已接受插队请求;不代表当前工具已中断。',
929
+ 'send.steerUnconfirmed': '插队结果未确认,消息可能已送达。请先检查会话与队列,勿直接重复发送。',
930
+ 'send.steerRejected': '插队未成功:{msg}。未自动改为排队。',
931
+ 'send.steerSlash': '斜杠命令不支持插队发送,请使用排队发送执行命令。',
932
+ 'send.contextChanged': '已切换会话或服务器,本次消息未发送。',
910
933
  'a11y.hostAdmin': '主机管理', 'a11y.refresh': '刷新', 'a11y.more': '更多操作', 'a11y.moreTitle': '指令/权限/模型',
911
934
  'conn.on': '已连接', 'conn.off': '未连接', 'conn.reconnecting': '连接中断,正在重连…',
912
935
  'conn.reconnectIn': '重连中 {n}s', 'conn.offline': '离线', 'conn.failed': '连接失败',
@@ -1139,6 +1162,17 @@
1139
1162
  'time.justNow': '刚刚', 'time.minAgo': ' 分钟前', 'time.hourAgo': ' 小时前'
1140
1163
  },
1141
1164
  en: {
1165
+ 'settings.steerSendTitle': 'Enable hold-to-steer sending', 'settings.steerSendDesc': 'While busy, hold to switch modes and release to send. Move to cancel. Off by default.',
1166
+ 'settings.busySendTitle': 'Default sending mode while busy', 'settings.busySendDesc': 'Click and Enter use the default; hold uses the other mode. Normal sending when idle.',
1167
+ 'send.queueButton': 'Queue', 'send.steerButton': 'Steer', 'send.submitting': 'Submitting…',
1168
+ 'send.holdHint': 'Hold to switch modes; release to send, move to cancel', 'send.releaseSteer': 'Release to steer', 'send.releaseQueue': 'Release to queue',
1169
+ 'send.steerPending': 'Submitting steering request, not yet confirmed. The current task may still be running.',
1170
+ 'send.steerSlow': 'Still waiting for DSH to confirm. Do not resend; the current task may still be running.',
1171
+ 'send.steerAccepted': 'DSH accepted the steering request. The current tool may still be running.',
1172
+ 'send.steerUnconfirmed': 'Steering is unconfirmed; the message may have arrived. Check the conversation and queue before resending.',
1173
+ 'send.steerRejected': 'Steering failed: {msg}. Not automatically queued.',
1174
+ 'send.steerSlash': 'Slash commands cannot be sent as steering. Use queue mode to execute the command.',
1175
+ 'send.contextChanged': 'Conversation or server changed; this message was not sent.',
1142
1176
  'a11y.hostAdmin': 'Host admin', 'a11y.refresh': 'Refresh', 'a11y.more': 'More actions', 'a11y.moreTitle': 'Commands / Permissions / Models',
1143
1177
  'conn.on': 'Connected', 'conn.off': 'Offline', 'conn.reconnecting': 'Connection lost, reconnecting…',
1144
1178
  'conn.reconnectIn': 'Reconnecting {n}s', 'conn.offline': 'Offline', 'conn.failed': 'Connection failed',
package/public/styles.css CHANGED
@@ -1319,3 +1319,9 @@ button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible
1319
1319
  .update-download-progress { margin-top: 12px; font-size: 12px; line-height: 1.6; overflow-wrap: anywhere; }
1320
1320
  .update-download-progress progress { display: block; width: 100%; height: 10px; margin: 7px 0; accent-color: var(--dsr-accent, #2563eb); }
1321
1321
  #update-download-detail { color: var(--dsr-text-muted); font-variant-numeric: tabular-nums; }
1322
+ /* Sending intent remains visible while waiting for a steering acknowledgement. */
1323
+ .composer-send-status { padding: 6px 12px; font-size: 12px; line-height: 1.5; overflow-wrap: anywhere; }
1324
+ .composer-send-status[data-phase="pending"], .composer-send-status[data-phase="unknown"], .composer-send-status[data-phase="error"] { color: var(--dsr-accent-strong); font-weight: 600; }
1325
+ #btn-send, #btn-fs-send { touch-action: none; user-select: none; -webkit-touch-callout: none; }
1326
+ #btn-send.steer-mode, #btn-fs-send.steer-mode { outline: 2px solid currentColor; outline-offset: 2px; }
1327
+ .send-hold-ready { box-shadow: 0 0 0 3px var(--dsr-accent-strong); }
@@ -1,9 +1,9 @@
1
1
  {
2
- "version": "0.7.0-rc.2",
2
+ "version": "0.7.0-rc.3",
3
3
  "apkUrl": "dsh-remote.apk",
4
- "sha256": "71a5cf1849e6eecc3ed72e6d0b3aec176ff9fbc75e50876831d09135c1af3efc",
5
- "releasedAt": "2026-09-19T05:04:05.773Z",
6
- "notes": "0.7.0-rc.2:修复令牌文件丢失后网关状态误判,健康探测可恢复原令牌,令牌写盘失败拒绝启动;管理认证异常与启停失败显示明确提示。插件网关支持配置 IPv6 监听地址。延续 rc.1 插件中心的搜索、安装、更新、卸载、启停、任务日志与配置备份功能。此为测试版,Android 真机与 Linux/fnOS 使用场景仍待验收。",
4
+ "sha256": "482490d9801a9e5c947fe02e3c7636b39c7089db8b38f55d732a963d848ee3fa",
5
+ "releasedAt": "2026-09-20T09:53:01.965Z",
6
+ "notes": "0.7.0-rc.3:手机端新增可选长按插队发送,默认关闭,可设置繁忙时默认排队或插队;直接提交插队请求,等待、超时与已接受状态分别提示,避免误认为工具已中断,失败时保留草稿。合入鸿蒙客户端、平板分栏与服务卡片源码,完善跨端接续接口;修复 WebSocket Ping 跨包组帧及接续接口非法输入处理。保留 rc.2 令牌恢复、认证状态和 IPv6 监听修复。本次提供 Android APK 与网关,未提供签名鸿蒙 HAP;鸿蒙大文件哈希、断点续传、快速切换服务器及 Android/Linux/fnOS 真机场景仍待验收。",
7
7
  "history": [
8
8
  {
9
9
  "version": "0.6.26",
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.7.0-rc.2"
2
+ "version": "0.7.0-rc.3"
3
3
  }