fix(host): 根因——spawn 的 detached:true 让 PowerShell 起来即退;feat: 三种跟随方式(默认跟随前台)
根因(对照实验):spawn('powershell', [...], {detached:true}) 在 Windows 上 → 子进程 exit 0、
脚本一行没跑(日志里连 start 都没有),表现就是『点打开没反应』;detached:false 完全正常。
现在 spawnShell:绝对路径优先 + 不 detached + 等心跳确认,失败把原因写进 status notes。
跟随方式(用户需求:切到哪个会话就跟哪个,且可配置):
- front(默认)跟随前台:客户端半订阅 uiSession.current,会话一换就上报 /window{action:front}
- latest 跟随最新事件;pinned 固定在 ⇄ 里选中的会话
设置入口:设置→悬浮窗(模式三选一);浮窗 ⇄ 里点会话=切到 pinned
快照新增 follow/front 字段;/window 支持 action=follow|front
This commit is contained in:
+80
-14
@@ -233,7 +233,11 @@ export function apply(ctx, config) {
|
||||
const titles = new Map() // sessionId -> 会话标题(来自 session/title 事件)
|
||||
const contextWindows = new Map() // sessionId -> 上下文窗口(来自 request/context 事件)
|
||||
let lastSessionId = null
|
||||
let pinnedSessionId = null // 用户在浮窗里选中的会话:多会话并行时不再被最新事件抢走
|
||||
let pinnedSessionId = null // 「固定」模式:用户在浮窗里选中的会话,不再被任何事件抢走
|
||||
let frontSessionId = null // 「跟随前台」模式:客户端半上报的当前显示会话(uiSession.current)
|
||||
// 跟随方式:front=跟随前台当前对话(默认);latest=跟随最新事件;pinned=固定某个会话
|
||||
let followMode = ['front', 'latest', 'pinned'].includes(floatCfg.follow) ? floatCfg.follow : 'front'
|
||||
const activeSessionId = () => (followMode === 'pinned' ? pinnedSessionId : (followMode === 'front' ? (frontSessionId || pinnedSessionId || lastSessionId) : lastSessionId))
|
||||
|
||||
function counter(id) {
|
||||
let c = counts.get(id)
|
||||
@@ -244,7 +248,8 @@ export function apply(ctx, config) {
|
||||
ctx.on('session/event', (session, event) => {
|
||||
try {
|
||||
const id = session?.id || session?.sessionId || 'unknown'
|
||||
if (!pinnedSessionId) lastSessionId = id
|
||||
// 「跟随前台」模式下,事件不再改变当前会话(前台由客户端半上报),但仍记录"最新事件"兜底
|
||||
if (!pinnedSessionId || followMode === 'latest') lastSessionId = id
|
||||
const c = counter(id)
|
||||
const type = String(event?.type || '')
|
||||
const d2 = () => event?.data || event || {}
|
||||
@@ -385,7 +390,7 @@ export function apply(ctx, config) {
|
||||
|
||||
function snapshot() {
|
||||
const sessions = (() => { try { return ctx.get('sessions')?.list?.() || [] } catch { return [] } })()
|
||||
const activeId = pinnedSessionId || lastSessionId
|
||||
const activeId = activeSessionId()
|
||||
let session = sessions.find((s) => s?.id === activeId) || sessions[sessions.length - 1] || null
|
||||
const id = session?.id || activeId
|
||||
return {
|
||||
@@ -393,6 +398,8 @@ export function apply(ctx, config) {
|
||||
at: Date.now(),
|
||||
sessionId: id || null,
|
||||
pinned: pinnedSessionId || null,
|
||||
front: frontSessionId || null,
|
||||
follow: followMode,
|
||||
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 },
|
||||
@@ -471,9 +478,17 @@ export function apply(ctx, config) {
|
||||
await windowAction(msg.action)
|
||||
} else if (msg.cmd === 'watch') {
|
||||
// 浮窗切换跟随的会话:'' / null 表示回到"跟随最新"
|
||||
if (msg.mode && ['front', 'latest', 'pinned'].includes(msg.mode)) followMode = msg.mode
|
||||
pinnedSessionId = msg.sessionId ? String(msg.sessionId) : null
|
||||
if (pinnedSessionId) lastSessionId = pinnedSessionId
|
||||
log('watch -> ' + (pinnedSessionId || 'latest'))
|
||||
if (pinnedSessionId) { followMode = 'pinned'; lastSessionId = pinnedSessionId }
|
||||
else if (!msg.mode) followMode = 'latest'
|
||||
log(`watch -> mode=${followMode} session=${pinnedSessionId || 'auto'}`)
|
||||
} else if (msg.cmd === 'follow') {
|
||||
if (['front', 'latest', 'pinned'].includes(msg.mode)) {
|
||||
followMode = msg.mode
|
||||
if (msg.mode !== 'pinned') pinnedSessionId = null
|
||||
}
|
||||
log(`outbox follow -> ${followMode}`)
|
||||
} else if (msg.cmd === 'session' && msg.action === 'create') {
|
||||
const { value } = await hostCall('create', {
|
||||
sessionId: randomUUID(),
|
||||
@@ -600,9 +615,25 @@ export function apply(ctx, config) {
|
||||
for await (const c of req) chunks.push(c)
|
||||
body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}')
|
||||
} catch { }
|
||||
// 客户端半的两条上报:前台会话 / 跟随方式
|
||||
if (body.action === 'front') {
|
||||
const sid = body.sessionId ? String(body.sessionId) : null
|
||||
if (sid !== frontSessionId) log(`front -> ${sid || 'none'}`)
|
||||
frontSessionId = sid
|
||||
res.writeHead(200, { ...cors, 'Content-Type': 'application/json; charset=utf-8' })
|
||||
res.end(JSON.stringify({ ok: true, follow: followMode, front: frontSessionId, alive: shellAlive() }))
|
||||
return
|
||||
}
|
||||
if (body.action === 'follow') {
|
||||
if (['front', 'latest', 'pinned'].includes(body.mode)) followMode = body.mode
|
||||
log(`follow mode -> ${followMode}`)
|
||||
res.writeHead(200, { ...cors, 'Content-Type': 'application/json; charset=utf-8' })
|
||||
res.end(JSON.stringify({ ok: true, follow: followMode, alive: shellAlive() }))
|
||||
return
|
||||
}
|
||||
const r = await windowAction(body.action)
|
||||
res.writeHead(200, { ...cors, 'Content-Type': 'application/json; charset=utf-8' })
|
||||
res.end(JSON.stringify({ ...r, alive: shellAlive() }))
|
||||
res.end(JSON.stringify({ ...r, alive: shellAlive(), follow: followMode }))
|
||||
return
|
||||
}
|
||||
res.writeHead(404, { ...cors, 'Content-Type': 'text/plain; charset=utf-8' })
|
||||
@@ -644,14 +675,49 @@ export function apply(ctx, config) {
|
||||
// 直接起 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) } }
|
||||
if (!fs.existsSync(ps1)) {
|
||||
const msg = `shell script not found: ${ps1}`
|
||||
log('spawnShell: ' + msg)
|
||||
status.notes.push(`${new Date().toISOString()} spawnShell FAILED ${msg}`)
|
||||
return { ok: false, error: msg }
|
||||
}
|
||||
// 绝对路径优先:DSH 应用进程的 PATH 未必和终端一致,裸名字可能找不到 powershell
|
||||
const sysRoot = process.env.SystemRoot || 'C:\\Windows'
|
||||
const candidates = [
|
||||
path.join(sysRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'),
|
||||
'powershell.exe',
|
||||
'pwsh.exe',
|
||||
]
|
||||
const { spawn } = await import('node:child_process')
|
||||
const before = aliveMtime()
|
||||
let lastErr = null
|
||||
for (const exe of candidates) {
|
||||
try {
|
||||
// 注意:**不要**加 detached:true —— 实测在 Windows 上 detached(CREATE_NO_WINDOW) 会让
|
||||
// PowerShell 宿主起来立刻退出(exit code 0、脚本一行都没跑),表现就是"点了打开没反应"。
|
||||
const child = spawn(exe, ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-WindowStyle', 'Hidden', '-File', ps1],
|
||||
{ stdio: 'ignore', windowsHide: true })
|
||||
child.on('error', (e) => { lastErr = String(e?.message || e) })
|
||||
child.unref()
|
||||
// 等心跳确认:真的起来了才算成功(否则就是"点了没反应")
|
||||
for (let i = 0; i < 15; i++) {
|
||||
await new Promise((r) => setTimeout(r, 400))
|
||||
if (aliveMtime() > before) {
|
||||
log(`spawnShell ok via ${exe}`)
|
||||
status.notes.push(`${new Date().toISOString()} spawnShell ok via ${exe}`)
|
||||
writeStatus()
|
||||
return { ok: true, via: exe }
|
||||
}
|
||||
if (lastErr) break
|
||||
}
|
||||
log(`spawnShell: ${exe} produced no heartbeat${lastErr ? ' (' + lastErr + ')' : ''}`)
|
||||
} catch (e) { lastErr = String(e?.message || e) }
|
||||
}
|
||||
const msg = `could not start float window (script=${ps1}, lastError=${lastErr || 'no heartbeat'})`
|
||||
log('spawnShell FAILED: ' + msg)
|
||||
status.notes.push(`${new Date().toISOString()} spawnShell FAILED ${msg}`)
|
||||
writeStatus()
|
||||
return { ok: false, error: msg }
|
||||
}
|
||||
|
||||
/** 浮窗已经在跑就投递命令,否则按需拉起;投递后要等心跳确认,不能"发给死人" */
|
||||
|
||||
Reference in New Issue
Block a user