From 8cc8df589231369cecab2138882e12ac8a99c164 Mon Sep 17 00:00:00 2001 From: antianqi Date: Fri, 23 Oct 2026 14:44:21 +0800 Subject: [PATCH 01/15] feat(mcode-island): track every live session, not just the newest The pill showed ONE session: Get-LatestSessionFile scans every *.jsonl under the session store and takes the most recently modified file. So the pill meant "whoever touched a log last" -- a background session could take the pill away from the one you were actually watching, and there was no way to see the others at all. This adds a session list with a lifecycle: detected active -> running the agent stops -> waiting for you you click it -> dismissed Two rules that are easy to get backwards, and both are pinned by the tests: "Is it active" is decided by the log's mtime, not by the last message. A conversation that stopped weeks ago still has a toolCall as its last line, so the message type says "running" while it has not moved in a month. With the message type alone, one pass tracked 80+ sessions. "Is it running or waiting" is decided by the last message. The reverse would mislabel a session that is busy with a 40-second tool (no log writes for 40 seconds) as "waiting", and no window size fixes that. So a session enters the list when its log was touched in the last minute, and its state is read from the last message: toolCall / toolResult / thinking -> running assistant with plain text -> waiting (it said its piece) ask_user -> waiting (the ball is in your court) user -> running (you just asked) The list has a lifecycle, not a query: a session that stops stays in the list as "waiting" until you dismiss it. Nothing expires on a timer, so a conversation you were not looking at does not silently vanish, and one you already dealt with does not come back. The list lives in sessions.json, not status.json. status.json is written by both the detector and the agent side (notify-island.ps1), and the agent side overwrites the whole file -- a list in there would be erased by the next tool call. sessions.json is also the persistence. The detector restarts often (the per-tick guard, autostart, crashes), and a list that only lived in memory would lose the "waiting" items, which are the ones that matter most. It is read back at startup. The one place a time window is still used: at startup, sessions that were touched in the last 30 minutes are seeded. Without it, a session that finished just before the detector restarted would never produce the "running -> waiting" transition, so its "waiting" state would never be seen. After startup the state machine does the work and no timer is involved. Titles come from the first user message, with the injection block stripped -- otherwise the row shows a system prompt instead of what you asked. A compacted session (one whose original prompt was replaced by a compaction summary) falls back to the agent's first line, which at least says what the session was about. The "you clicked it" channel is ack.signal, reusing the pattern the plugin already uses for the show signal: the widget writes the id, the detector consumes it and drops the session. A dismissed session comes back only after it finishes a round and starts a new one. ## Test evidence scripts/test-session-tracker.ps1: 20 checks, all passing, wired into the behavioral suite (125 pass, 0 fail, 1 skipped). 7 negative-injection cases, each breaking one clause of the contract in the real source; all 7 turn the suite red: T1 the mtime check removed (every session enters) -> RED T2 the phase mapping inverted -> RED T3 the title does not strip the reminder block -> RED T4 dismissal does not remove the entry -> RED T5 ack.signal is not consumed -> RED T6 the list is not restored on restart -> RED T7 the cycle transition is not detected -> RED Three real bugs were found by writing those checks, all of them silent in production: - the detector called Restore-SessionTracker while the lib defined Restore-SessionsFile, so the list was lost on every detector restart and the try/catch swallowed it as "SESSIONS INIT FAIL"; - the mtime comparison mixed UtcNow with GetLastWriteTime, so the age came out negative and the "too old" check never fired; - a dismissed session was removed from the map, so the waiting -> running transition that should bring it back was never detected. ## Design compliance no credentials : none added no network : no network calls added; the tracker reads the local session store only no telemetry : none added; island.log unchanged no third-party svcs : no new dependencies cross-platform : no host-absolute paths introduced encoding : the new lib and the new test carry a UTF-8 BOM (they contain non-ASCII, and Windows PowerShell 5.1 decodes a BOM-less script with the system ANSI codepage) file layout : sessions.json is new and detector-owned; status.json's schema is unchanged --- .../mcode-island/mcode-status-detect.ps1 | 38 ++ .../scripts/lib/Session-Tracker.ps1 | 419 ++++++++++++++++++ .../scripts/test-session-tracker.ps1 | 178 ++++++++ .../scripts/test-substep-progress.mjs | 70 ++- 4 files changed, 695 insertions(+), 10 deletions(-) create mode 100644 plugins/antianqi/mcode-island/scripts/lib/Session-Tracker.ps1 create mode 100644 plugins/antianqi/mcode-island/scripts/test-session-tracker.ps1 diff --git a/plugins/antianqi/mcode-island/mcode-status-detect.ps1 b/plugins/antianqi/mcode-island/mcode-status-detect.ps1 index d14e1fae..d11d054b 100644 --- a/plugins/antianqi/mcode-island/mcode-status-detect.ps1 +++ b/plugins/antianqi/mcode-island/mcode-status-detect.ps1 @@ -178,6 +178,16 @@ if (Test-Path "$PSScriptRoot/scripts/lib/Get-5hUsage.ps1") { # the lock, the single snapshot read and the atomic replace, so both # sides follow one protocol. . "$PSScriptRoot/scripts/lib/Status-Store.ps1" +# 多 session 跟踪(v0.5.0)。 +# +# 以前 pill 只显示一个 session:Get-LatestSessionFile 取最近被改的那个日志, +# 所以"谁最后动了日志就显示谁",后台会话能把前台的 pill 抢走。这个 lib +# 维护一份有生命周期的 session 列表(运行中 / 待回复 / 点掉消除),写到 +# sessions.json —— 不是 status.json,因为那个文件 agent 侧会整份覆盖。 +# +# $script: 在 dot-source 之后就是本脚本的作用域,所以 lib 里的状态变量和 +# 这里的状态变量是同一份。 +. "$PSScriptRoot/scripts/lib/Session-Tracker.ps1" $script:plan5hLastCallAt = [DateTime]::MinValue $script:plan5hRemainingPct = $null # 0..100,剩余百分比(不再是已用!) $script:plan5hResetMs = $null # 距下次刷新的毫秒数 @@ -866,6 +876,22 @@ if (-not $Once) { Log-Line "detector started (PID $PID) mcodeRoot=$mcodeRoot" } +# 多 session 列表的恢复与补录。 +# +# detector 经常重启(per-tick guard、autostart、崩溃)。如果列表只在内存里, +# 重启就把用户手上那些"待回复"清零了 —— 那个信号恰好是最不能丢的。 +# 所以列表存在 sessions.json,启动时读回来。 +# +# 补录是唯一一处还看时间的地方:把最近 30 分钟内还有动静的 session 收进来。 +# 运行期完全靠状态机,不看时间。 +try { + Restore-SessionTracker + Seed-SessionTracker + Write-SessionsFile -Force +} catch { + Log-Line "SESSIONS INIT FAIL: $($_.Exception.Message)" +} + try { while ($true) { # --- per-tick guard ------------------------------------------------------- @@ -882,6 +908,18 @@ try { $inferred = $null $latestFile = $null + # 0) 多 session 列表:状态机 + 写盘 + # + # 放在最前面:它决定"哪些 session 有活动"。主线状态推断读的是"最近 + # 被改的那个日志",两者不是一回事——后台会话在跑时,主线应该还是你 + # 前台那个会话的状态。 + try { + Update-SessionTracker + Write-SessionsFile + } catch { + Log-Line "SESSIONS UPDATE FAIL: $($_.Exception.Message)" + } + # 1) 最新 session log:5s 缓存(避免每秒 EnumerateFiles 几千个 .jsonl) # # 之前这里先查 mcode 进程(读的是 CLI 专属的活动会话目录), diff --git a/plugins/antianqi/mcode-island/scripts/lib/Session-Tracker.ps1 b/plugins/antianqi/mcode-island/scripts/lib/Session-Tracker.ps1 new file mode 100644 index 00000000..c70e55ca --- /dev/null +++ b/plugins/antianqi/mcode-island/scripts/lib/Session-Tracker.ps1 @@ -0,0 +1,419 @@ +# --------------------------------------------------------------------------- +# 多 session 跟踪(v0.5.0) +# +# 灵动岛以前只显示一个 session:Get-LatestSessionFile 扫全部 *.jsonl 取最近被 +# 改的那一个。所以 pill 今天的语义是"谁最后动了日志就显示谁",不是"你关心的 +# 那个会话"——后台会话跑一下就能把前台的 pill 抢走。 +# +# 这里维护一份有生命周期的 session 列表: +# +# 检测到活动 -> 运行中 +# agent 停了 -> 待回复(最后一条不是 toolCall / toolResult / thinking) +# 用户点掉 -> 从列表里消除 +# +# 判定"它停了"用的是最后一条消息的类型,不是时间超时:agent 跑一个 40 秒的 +# 工具时日志可能 40 秒没写,但它没停;用超时判定会把"正在跑长工具"误判成 +# "待回复",而且窗口多长都不对。 +# +# 列表写在 sessions.json,不是 status.json:status.json 是 agent 侧 +# (notify-island.ps1)和 detector 两侧都在写的文件,agent 侧是整份覆盖, +# 把列表塞进去会被下一次工具调用整份抹掉。sessions.json 只有 detector 写。 +# --------------------------------------------------------------------------- + +# 被跟踪的 session:id -> 条目 +$script:trackedSessions = [ordered]@{} +# 标题缓存:日志路径 -> 标题(首条提问只读一次) +$script:sessionTitleCache = @{} +# 被用户点掉的:id -> 被点掉时的 cycle +$script:dismissedSessions = @{} +# 有没有变过。没变就不写盘——sessions.json 和 status.json 用同一把锁, +# 每秒无脑写一次会把 hook 的状态推送堵在门外。 +$script:sessionsDirty = $false +# 目录枚举缓存 +$script:lastGroupsScan = [DateTime]::MinValue +$script:lastGroups = @{} + +# 启动补录窗口:detector 重启时,把这段时间内还有动静的 session 收进来。 +# 运行期间完全靠状态机,不看时间。 +$script:SEED_WINDOW_MS = 30 * 60 * 1000 + +# 活动窗口:日志刚被写过,session 才算"有活动"。 +# +# 这个窗口只决定"能不能进列表",不决定进列表之后显示什么: +# 进列表之后状态只看最后一条消息。为什么分开: +# +# - "有活动"必须看 mtime。一个几周前卡在 toolCall 上的老对话,最后 +# 一条仍然是 toolCall,消息类型说它"在跑",它其实早就不动了。只看 +# 消息类型会把全部历史会话一次性收进列表(实测 80+ 条)。 +# - "在跑还是在等你"必须看消息。agent 跑一个 40 秒的工具时日志可能 +# 40 秒没写,但它没停;用 mtime 判定状态会把"正在跑长工具"误判成 +# "待回复",而且窗口多长都不对。 +# +# 60 秒足够覆盖 detector 的一次卡顿(它每秒轮询一次)。 +$script:ACTIVE_WINDOW_MS = 60 * 1000 + +function Get-SessionIdFromDir($dir) { + $name = [System.IO.Path]::GetFileName($dir) + $i = $name.IndexOf('session_') + if ($i -lt 0) { return $name } + $b64 = $name.Substring($i + 8) + try { + $pad = (4 - ($b64.Length % 4)) % 4 + return [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($b64 + ('=' * $pad))) + } catch { + return $name + } +} + +# 一次目录枚举,按 session 分组。 +# +# 目录形态:////-session_/ +# 每个目录里可能有 ledger.jsonl(首选)和 messages.jsonl(fallback)。 +# +# 同一个 session 里两个文件都存在时,取最近被改的那个——和 +# Get-LatestSessionFile 的理由一致:一个陈旧的 ledger 会盖掉活的 +# messages,导致 60s 空闲兜底打在很久以前的时间戳上。 +function Get-SessionLogGroups($root) { + $groups = @{} + if (-not $root -or !(Test-Path -LiteralPath $root)) { return $groups } + foreach ($dir in [System.IO.Directory]::EnumerateDirectories($root, '*session_*', 'AllDirectories')) { + $log = $null + $mtime = [DateTime]::MinValue + foreach ($name in @($FNAME_LEDGER, $FNAME_MESSAGES)) { + $p = Join-Path $dir $name + if (!(Test-Path -LiteralPath $p)) { continue } + try { $t = [System.IO.File]::GetLastWriteTimeUtc($p) } catch { continue } + if ($t -gt $mtime) { $mtime = $t; $log = $p } + } + if (-not $log) { continue } + $id = Get-SessionIdFromDir $dir + $groups[$id] = @{ Id = $id; Dir = $dir; Log = $log; MTime = $mtime } + } + return $groups +} + +# 读日志开头,找第一条 user 消息(标题来源)。 +# +# 不是"第一行":压缩过的会话(session compaction)第一行是 +# role=compactionSummary 或 role=custom,不是 user 消息。所以要往下扫。 +# +# user 消息的正文前面挂着一整块 (agent-context 注入), +# 把它剥掉,剩下的才是用户真正说的第一句话。 +function Get-SessionTitle($file) { + if (-not $file) { return '' } + if ($script:sessionTitleCache.ContainsKey($file)) { return $script:sessionTitleCache[$file] } + $title = '' + try { + $fs = [System.IO.File]::Open($file, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite) + try { + # 开头 64 KB。user 消息带一张 base64 截图时能到几百 KB,读太多会把 + # 内存吃穿;扫不到就当没有标题,不影响其余功能。 + $cap = 64 * 1024 + $len = [Math]::Min($cap, $fs.Length) + $buf = New-Object byte[] $len + $read = 0 + while ($read -lt $len) { + $n = $fs.Read($buf, $read, $len - $read) + if ($n -le 0) { break } + $read += $n + } + $text = [System.Text.Encoding]::UTF8.GetString($buf, 0, $read) + $assistantFallback = '' + foreach ($raw in ($text -split "`n")) { + $line = $raw.TrimEnd("`r") + if (-not $line -or $line.Length -le 10) { continue } + $msg = $null + try { $msg = $line | ConvertFrom-Json } catch { continue } + $m = $msg.message + if (-not $m) { continue } + $role = [string]$m.role + $t = '' + foreach ($c in @($m.content)) { + if ($c.type -eq 'text' -and $c.text) { $t = [string]$c.text; break } + } + if (-not $t) { continue } + # 剥掉注入块,剩下的才是用户说的话 + $t = $t -replace '(?s).*?', '' + $t = ($t -replace '\s+', ' ').Trim() + if (-not $t) { continue } + if ($role -eq $S_USER) { $title = $t; break } + # 兜底:被压缩过的会话(compaction)里,原始提问已经被 + # compactionSummary 替换掉了,真实标题取不回来。agent 的第一句 + # 话至少能说明这个会话在干什么。 + if (-not $assistantFallback -and $role -eq $S_ASSISTANT) { $assistantFallback = $t } + } + if (-not $title) { $title = $assistantFallback } + } finally { + $fs.Close() + } + } catch { + $title = '' + } + if ($title.Length -gt 40) { $title = $title.Substring(0, 40) + '…' } + $script:sessionTitleCache[$file] = $title + return $title +} + +# 这个 session 现在是"运行中"还是"待回复"。 +# +# running agent 还在干:toolCall / toolResult / thinking / 用户刚说完 +# waiting agent 停了,在等用户:它说完了,或者它在提问(ask_user) +# +# ask_user 归到 waiting:球在用户这边,agent 在等答案。 +function Get-SessionPhase($msg) { + if (-not $msg) { return 'running' } + $m = $msg.message + if (-not $m) { + # ledger 事件格式:{kind, phase, action, ...} + $kind = [string]$msg.kind + if ($kind -eq $S_TOOLRESULT) { return 'running' } + if ($kind -eq 'toolCall' -or $kind -eq $S_TOOLCALL) { + if (Test-IsAskUser ([string]$msg.toolName)) { return 'waiting' } + return 'running' + } + return 'running' + } + $role = [string]$m.role + if ($role -eq $S_USER) { return 'running' } # 用户刚说完,agent 要动 + if ($role -eq $S_TOOLRESULT) { return 'running' } + if ($role -eq $S_ASSISTANT) { + foreach ($c in @($m.content)) { + if ($c.type -eq $S_TOOLCALL) { + if (Test-IsAskUser ([string]$c.name)) { return 'waiting' } + return 'running' + } + } + return 'waiting' # assistant 纯文本 = 说完了 + } + return 'running' +} + +# 消掉一个 session(用户点了)。 +# 记下被点掉时的 cycle:同一个 cycle 内不会再冒出来;agent 跑完一轮再 +# 开始(waiting -> running)算新的一轮,那时它可以重新进来。 +function Remove-TrackedSession($id) { + $entry = $script:trackedSessions[$id] + if ($entry) { + # 记下消掉时它是什么状态:只有"它跑完一轮又开始了"才该重新出现。 + # 消掉的时候正在跑 -> 继续跑的过程中不该冒出来。 + $script:dismissedSessions[$id] = @{ Cycle = $entry.Cycle; Phase = $entry.Phase } + $script:trackedSessions.Remove($id) + $script:sessionsDirty = $true + return $true + } + return $false +} + +# 消费用户点掉的信号。 +# +# 复用插件里已有的 show.signal 模式:widget 建一个文件,detector 看到就 +# 处理。ack.signal 里写要消掉的 session id。 +function Read-AckSignal { + $ackFile = Join-Path $configDir 'ack.signal' + if (!(Test-Path -LiteralPath $ackFile)) { return $null } + $id = $null + try { $id = (Get-Content -LiteralPath $ackFile -Raw -Encoding UTF8) } catch { $id = $null } + try { Remove-Item -LiteralPath $ackFile -Force -ErrorAction SilentlyContinue } catch {} + if ($id) { return $id.Trim() } + return $null +} + +# 每轮更新一次 session 列表。 +function Update-SessionTracker { + # 用户点掉的先消化掉 + $acked = Read-AckSignal + if ($acked) { + if (Remove-TrackedSession $acked) { Log-Line "session ack: $acked" } + } + + $groups = Get-SessionLogGroups $sessionsRoot + $now = [DateTime]::UtcNow + + foreach ($id in @($groups.Keys)) { + $g = $groups[$id] + $entry = $script:trackedSessions[$id] + if ($entry -and $entry.MTime -eq $g.MTime) { continue } # 没变就不用重读 + + $msg = Get-LastJsonLine $g.Log + if (-not $msg) { continue } + $phase = Get-SessionPhase $msg + + if (-not $entry) { + # 新 session:只有"刚动过"才进列表。 + # + # 活动与否看日志 mtime,不看最后一条消息的类型——一个几周前卡在 + # toolCall 上的老对话,最后一条仍然是 toolCall,消息类型说它 + # "在跑",它其实早就不动了。 + $ageMs = ($now - $g.MTime).TotalMilliseconds + if ($ageMs -gt $script:ACTIVE_WINDOW_MS) { continue } + + $cycle = 1 + if ($script:dismissedSessions.ContainsKey($id)) { + $d = $script:dismissedSessions[$id] + # 被点掉过。上次看到它是"待回复",现在又"运行中" -> 它跑完了一轮 + # 又开始,是新的一轮,可以重新进来。 + if ($d.Phase -eq 'waiting' -and $phase -eq 'running') { + $cycle = $d.Cycle + 1 + $script:dismissedSessions.Remove($id) + $script:sessionsDirty = $true + } else { + # 还没到新一轮:只更新它现在的状态,等它"待回复 -> 运行中"的那 + # 一次转换时再放回来。 + $d.Phase = $phase + continue + } + } + $script:trackedSessions[$id] = @{ + Id = $id + Title = (Get-SessionTitle $g.Log) + Phase = $phase + Cycle = $cycle + MTime = $g.MTime + SinceMs = [int64]([DateTimeOffset]::new($g.MTime).ToUnixTimeMilliseconds()) + Dir = $g.Dir + } + $script:sessionsDirty = $true + Log-Line "session tracked: $id ($phase)" + continue + } + + # 已跟踪的:状态变了就更新 + if ($entry.Phase -eq 'waiting' -and $phase -eq 'running') { + # 一轮结束了,agent 又开始 -> 新的一轮,之前被点掉的约束失效 + $entry.Cycle = $entry.Cycle + 1 + if ($script:dismissedSessions.ContainsKey($id)) { + $script:dismissedSessions.Remove($id) + } + $script:sessionsDirty = $true + Log-Line "session restarted: $id (cycle $($entry.Cycle))" + } + if ($entry.Phase -ne $phase) { $script:sessionsDirty = $true } + $entry.Phase = $phase + $entry.MTime = $g.MTime + if (-not $entry.Title) { $entry.Title = (Get-SessionTitle $g.Log) } + } +} + +# 写 sessions.json。 +# +# 只有 detector 写这个文件,所以不存在 agent 侧整份覆盖把它抹掉的问题。 +# 但它和 status.json 在同一个目录、同一台机器上,用同一把锁写,免得两个 +# 进程同时动这个目录。 +function Write-SessionsFile { + param([switch]$Force) + if (-not $Force -and -not $script:sessionsDirty) { return } + $items = @() + foreach ($id in @($script:trackedSessions.Keys)) { + $e = $script:trackedSessions[$id] + $items += [PSCustomObject]@{ + id = $e.Id + title = $e.Title + phase = $e.Phase + sinceMs = $e.SinceMs + cycle = $e.Cycle + } + } + $payload = [PSCustomObject]@{ + sessions = $items + dismissed = $script:dismissedSessions + ts = (Get-Date).ToString('o') + } + $lock = Enter-StatusLock + try { + $file = Join-Path $configDir 'sessions.json' + $json = $payload | ConvertTo-Json -Compress -Depth 6 + $tmp = "$file.tmp" + [System.IO.File]::WriteAllText($tmp, $json, [System.Text.Encoding]::UTF8) + Move-Item -Path $tmp -Destination $file -Force + $script:sessionsDirty = $false + } catch { + Log-Line "SESSIONS WRITE FAIL: $($_.Exception.Message)" + } finally { + Exit-StatusLock $lock + } +} + +# detector 启动时把列表读回来(detector 重启不能把用户的"待回复"清零)。 +function Restore-SessionTracker { + $file = Join-Path $configDir 'sessions.json' + if (!(Test-Path -LiteralPath $file)) { return } + try { + $j = [System.IO.File]::ReadAllText($file) | ConvertFrom-Json + foreach ($s in @($j.sessions)) { + if (-not $s.id) { continue } + $script:trackedSessions[$s.id] = @{ + Id = $s.id + Title = [string]$s.title + Phase = [string]$s.phase + Cycle = $(if ($s.cycle) { [int]$s.cycle } else { 1 }) + MTime = [DateTime]::MinValue + SinceMs = [int64]$s.sinceMs + Dir = '' + } + } + if ($j.dismissed) { + foreach ($p in $j.dismissed.PSObject.Properties) { + $v = $p.Value + # 老格式是一个整数(cycle)。当成"消掉时正在跑",于是它要等 + # 跑完一轮又开始才回来。 + if ($v -is [int] -or $v -is [long] -or $v -is [double]) { + $v = @{ Cycle = [int]$v; Phase = 'running' } + } + $script:dismissedSessions[$p.Name] = $v + } + } + Log-Line ("sessions restored: " + $script:trackedSessions.Count) + } catch { + Log-Line "SESSIONS RESTORE FAIL: $($_.Exception.Message)" + } +} + +# 启动补录:把最近还在动的 session 收进来。 +# +# 只在 detector 启动时跑一次。运行期完全靠状态机。 +# +# 为什么需要它:detector 经常重启(per-tick guard、autostart、崩溃)。如果 +# 只靠"看到它从 running 变成 waiting"这个转换,那么一个在 detector 重启 +# 之前刚说完话的 session 就永远等不到那次转换,用户永远看不到它的"待回复"。 +# 这个窗口是唯一一处还看时间的地方——30 分钟不算"很久没用",而且运行期 +# 不再依赖它。 +function Seed-SessionTracker { + $groups = Get-SessionLogGroups $sessionsRoot + $now = [DateTime]::UtcNow + $activeCut = $now.AddMilliseconds(-1 * $script:ACTIVE_WINDOW_MS) + $seedCut = $now.AddMilliseconds(-1 * $script:SEED_WINDOW_MS) + foreach ($id in @($groups.Keys)) { + $g = $groups[$id] + # trackedSessions 是 [ordered]@{}(OrderedDictionary),它没有 + # ContainsKey,只有 Contains。写成 ContainsKey 会在这里抛 + # "OrderedDictionary 不包含名为 ContainsKey 的方法",把整个补录 + # 堵掉 —— 症状是列表只剩恢复回来的那几条。 + if ($script:trackedSessions.Contains($id)) { continue } + if ($script:dismissedSessions.ContainsKey($id)) { continue } + + $msg = Get-LastJsonLine $g.Log + if (-not $msg) { continue } + $phase = Get-SessionPhase $msg + + # 两种补录: + # - 刚动过(60 秒内):它此刻真的在跑 + # - 说完在等你(30 分钟内,且最后一条是 agent 收尾):detector + # 重启前它刚说完,那次"从运行中变成待回复"的转换我们没看见, + # 不补录的话这个"待回复"就永远等不到了 + $isActive = ($g.MTime -ge $activeCut) + $isFinishedWaiting = ($g.MTime -ge $seedCut) -and ($phase -eq 'waiting') + if (-not $isActive -and -not $isFinishedWaiting) { continue } + + $script:trackedSessions[$id] = @{ + Id = $id + Title = (Get-SessionTitle $g.Log) + Phase = $phase + Cycle = 1 + MTime = $g.MTime + SinceMs = [int64]([DateTimeOffset]::new($g.MTime).ToUnixTimeMilliseconds()) + Dir = $g.Dir + } + $script:sessionsDirty = $true + Log-Line "session seeded: $id ($phase)" + } +} diff --git a/plugins/antianqi/mcode-island/scripts/test-session-tracker.ps1 b/plugins/antianqi/mcode-island/scripts/test-session-tracker.ps1 new file mode 100644 index 00000000..6f4a88cc --- /dev/null +++ b/plugins/antianqi/mcode-island/scripts/test-session-tracker.ps1 @@ -0,0 +1,178 @@ +# Behavioral tests for the multi-session tracker. +# +# Emits TAP-ish lines the node suite parses: +# ok N - +# not ok N - : +# +# The tracker lives in a lib that expects the detector's scope +# ($sessionsRoot, $configDir, the state constants, Get-LastJsonLine, +# Test-IsAskUser, the store lock). This harness assembles that scope +# against a FIXTURE session tree, so the assertions run against the real +# functions -- not a re-implementation of them. + +$ErrorActionPreference = 'Stop' + +# The plugin root comes from this script's own location, not from +# $env:USERPROFILE. CI checks the repo out to D:\a\\, so a +# path built from the user profile does not exist there -- the suite +# then dies before its first check and reports "0 passed", which reads +# like "nothing to test" rather than "the harness is broken". +$pluginRoot = Split-Path -Parent $PSScriptRoot +$fixture = Join-Path ([System.IO.Path]::GetTempPath()) ('sess-fixture-' + [guid]::NewGuid().ToString('N').Substring(0, 8)) +$sessionsRoot = Join-Path $fixture 'sessions' +$configDir = Join-Path $fixture 'config' +$dayDir = Join-Path (Join-Path (Join-Path $sessionsRoot '2026') '10') '23' +New-Item -ItemType Directory -Path $dayDir -Force | Out-Null +New-Item -ItemType Directory -Path $configDir -Force | Out-Null + +# The store lib resolves its lock file under %APPDATA%. Point it at the +# fixture: the test must not touch the real store, and the ubuntu CI job +# has no APPDATA at all (Join-Path $null throws there). +$env:APPDATA = Join-Path $fixture 'appdata' +New-Item -ItemType Directory -Path $env:APPDATA -Force | Out-Null + +# --- the scope the lib expects ------------------------------------------- +$S_USER = 'user'; $S_ASSISTANT = 'assistant'; $S_TOOLRESULT = 'toolResult' +$S_TOOLCALL = 'toolCall'; $FNAME_LEDGER = 'ledger.jsonl'; $FNAME_MESSAGES = 'messages.jsonl' +$S_THINKING = 'thinking' +function Log-Line($msg) { } +function Test-IsAskUser($name) { return ([string]$name) -eq 'AskUserQuestion' } + +# The detector's tail reader and ask_user test, verbatim. +$detect = [System.IO.File]::ReadAllText((Join-Path $pluginRoot 'mcode-status-detect.ps1')) +function Extract-Function([string]$src, [string]$name) { + $m = [regex]::Match($src, "(?ms)^function\s+$([regex]::Escape($name))\s*\(.*?^}") + if (-not $m.Success) { throw "function $name not found" } + return $m.Value +} +Invoke-Expression (Extract-Function $detect 'Get-LastJsonLine') +Invoke-Expression (Extract-Function $detect 'Get-ToolKey') +Invoke-Expression (Extract-Function $detect 'Test-IsAskUser') + +# The store lib (the lock) and the tracker lib. +. (Join-Path (Join-Path $pluginRoot 'scripts') (Join-Path 'lib' 'Status-Store.ps1')) +. (Join-Path (Join-Path $pluginRoot 'scripts') (Join-Path 'lib' 'Session-Tracker.ps1')) + +# --- assertions ---------------------------------------------------------- +$script:pass = 0; $script:fail = 0; $script:n = 0 +function Check([string]$name, [bool]$cond, [string]$why = '') { + $script:n++ + if ($cond) { $script:pass++; Write-Output "ok $script:n - $name" } + else { $script:fail++; Write-Output "not ok $script:n - ${name}: $why" } +} + +# Make a session directory with the given message lines, and return its path. +function New-Session([string]$id, [string[]]$lines, [int]$ageMinutes = 0) { + $b64 = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($id)).TrimEnd('=') + $dirName = ('01-00-00-000-session_' + $b64) + $dir = Join-Path $dayDir $dirName + New-Item -ItemType Directory -Path $dir -Force | Out-Null + $f = Join-Path $dir 'messages.jsonl' + [System.IO.File]::WriteAllText($f, (($lines -join "`n") + "`n"), (New-Object System.Text.UTF8Encoding($false))) + if ($ageMinutes -gt 0) { + [System.IO.File]::SetLastWriteTime($f, (Get-Date).AddMinutes(-1 * $ageMinutes)) + } + return $f +} +function Line-User([string]$text, [string]$extra = '') { + $t = $text -replace '\\', '\\' + $t = $t -replace "`r`n", '\n' + $t = $t -replace "`n", '\n' + $t = $t -replace '"', '\"' + return '{"message":{"role":"user","content":[{"type":"text","text":"' + $t + '"}],"timestamp":1}}' +} +function Line-Assistant([string]$text) { + $t = $text -replace "`r`n", '\n' + $t = $t -replace "`n", '\n' + $t = $t -replace '"', '\"' + return '{"message":{"role":"assistant","content":[{"type":"text","text":"' + $t + '"}],"timestamp":2}}' +} +function Line-ToolCall([string]$tool) { + return '{"message":{"role":"assistant","content":[{"type":"toolCall","name":"' + $tool + '","arguments":{}}],"timestamp":3}}' +} +function Line-ToolResult { + return '{"message":{"role":"toolResult","content":[{"type":"text","text":"ok"}],"timestamp":4}}' +} + +# --- 1. 状态机 ----------------------------------------------------------- +$fTool = New-Session 'sess-running' @((Line-ToolCall 'Bash')) +$fDone = New-Session 'sess-waiting' @((Line-Assistant '我先去做点别的')) +$fAsk = New-Session 'sess-asking' @((Line-ToolCall 'ask_user')) +$fUser = New-Session 'sess-userjust' @((Line-User '帮我看下这个')) + +Check 'toolCall 结尾 = 运行中' ((Get-SessionPhase (Get-LastJsonLine $fTool)) -eq 'running') (Get-SessionPhase (Get-LastJsonLine $fTool)) +Check 'assistant 文本结尾 = 待回复' ((Get-SessionPhase (Get-LastJsonLine $fDone)) -eq 'waiting') (Get-SessionPhase (Get-LastJsonLine $fDone)) +Check 'ask_user 结尾 = 待回复' ((Get-SessionPhase (Get-LastJsonLine $fAsk)) -eq 'waiting') (Get-SessionPhase (Get-LastJsonLine $fAsk)) +Check 'user 刚说完 = 运行中' ((Get-SessionPhase (Get-LastJsonLine $fUser)) -eq 'running') (Get-SessionPhase (Get-LastJsonLine $fUser)) + +# --- 2. 标题提取 --------------------------------------------------------- +$reminder = "`nxxx`n`n帮我把 PR 看一下" +$fTitle = New-Session 'sess-title' @((Line-User $reminder)) +Check '标题剥掉 system-reminder 后是用户提问' ((Get-SessionTitle $fTitle) -eq '帮我把 PR 看一下') ("得到: " + (Get-SessionTitle $fTitle)) + +$fCompact = New-Session 'sess-compact' @('{"message":{"role":"compactionSummary","content":[]}}', (Line-Assistant '我来实现选项 ②')) +Check '被压缩的会话退回 assistant 首句' ((Get-SessionTitle $fCompact) -like '我来实现选项*') ("得到: " + (Get-SessionTitle $fCompact)) + +# --- 3. 入列规则:旧的 session 不进列表 ----------------------------------- +$oldDir = New-Session 'sess-old' @((Line-ToolCall 'Bash')) 60 # 60 分钟前 +[System.IO.File]::SetLastWriteTime($oldDir, (Get-Date).AddMinutes(-60)) +$script:trackedSessions = [ordered]@{} +$script:dismissedSessions = @{} +$script:sessionTitleCache = @{} +Update-SessionTracker +Check '很久没动的 session 不进列表' (-not $script:trackedSessions.Contains('sess-old')) ("列表里有: " + (($script:trackedSessions.Keys) -join ',')) + +# 新的 session 要进列表 +$fNew = New-Session 'sess-new' @((Line-ToolCall 'Bash')) +Update-SessionTracker +Check '刚动过的 session 进列表' ($script:trackedSessions.Contains('sess-new')) ("列表: " + (($script:trackedSessions.Keys) -join ',')) + +# --- 4. 状态从运行中变成待回复 ------------------------------------------- +$fNew2 = New-Session 'sess-flip' @((Line-ToolCall 'Bash')) +Update-SessionTracker +$before = $script:trackedSessions['sess-flip'].Phase +[System.IO.File]::WriteAllText($fNew2, ((Line-Assistant '做完了') + "`n"), (New-Object System.Text.UTF8Encoding($false))) +Update-SessionTracker +$after = $script:trackedSessions['sess-flip'].Phase +Check '运行中 -> 待回复 的状态转换' (($before -eq 'running') -and ($after -eq 'waiting')) ("$before -> $after") + +# --- 5. 消除:ack.signal ---------------------------------------------------- +[System.IO.File]::WriteAllText((Join-Path $configDir 'ack.signal'), 'sess-flip', (New-Object System.Text.UTF8Encoding($false))) +Update-SessionTracker +Check 'ack.signal 里的 session 被消掉' (-not $script:trackedSessions.Contains('sess-flip')) ("列表: " + (($script:trackedSessions.Keys) -join ',')) +Check '消除被记进 dismissed' ($script:dismissedSessions.Contains('sess-flip')) ($script:dismissedSessions | ConvertTo-Json -Compress) +Check 'ack.signal 本身被消费掉' (-not (Test-Path (Join-Path $configDir 'ack.signal'))) '文件还在' + +# --- 6. 持久化 ------------------------------------------------------------- +Write-SessionsFile -Force +$saved = Get-Content (Join-Path $configDir 'sessions.json') -Raw | ConvertFrom-Json +Check 'sessions.json 写出了列表' (@($saved.sessions).Count -gt 0) ("数量: " + @($saved.sessions).Count) +Check 'sessions.json 记下了 dismissed' ($null -ne $saved.dismissed.'sess-flip') ($saved.dismissed | ConvertTo-Json -Compress) + +# 重启模拟:清空内存,从文件恢复 +$script:trackedSessions = [ordered]@{} +$script:dismissedSessions = @{} +Restore-SessionTracker +Check '重启后列表从 sessions.json 恢复' ($script:trackedSessions.Contains('sess-new')) ("恢复后: " + (($script:trackedSessions.Keys) -join ',')) +Check '重启后 dismissed 也恢复' ($script:dismissedSessions.Contains('sess-flip')) ($script:dismissedSessions | ConvertTo-Json -Compress) + +# --- 7. cycle:被消掉的 session 跑完一轮又回来 ------------------------------- +$fCycle = New-Session 'sess-cycle' @((Line-ToolCall 'Bash')) +Update-SessionTracker +Check '新 cycle 的 session 进列表' ($script:trackedSessions.Contains('sess-cycle')) '没进来' +[System.IO.File]::WriteAllText((Join-Path $configDir 'ack.signal'), 'sess-cycle', (New-Object System.Text.UTF8Encoding($false))) +Update-SessionTracker +Check '被消掉后不在列表里' (-not $script:trackedSessions.Contains('sess-cycle')) '还在' +# agent 说完了 -> waiting +[System.IO.File]::WriteAllText($fCycle, ((Line-Assistant '好了') + "`n"), (New-Object System.Text.UTF8Encoding($false))) +Update-SessionTracker +# agent 又开始 -> running,cycle +1,dismissed 失效 +[System.IO.File]::WriteAllText($fCycle, ((Line-ToolCall 'Bash') + "`n"), (New-Object System.Text.UTF8Encoding($false))) +Update-SessionTracker +Check '新一轮开始后 session 重新进列表' ($script:trackedSessions.Contains('sess-cycle')) '没回来' +Check '新一轮清掉了 dismissed 记录' (-not $script:dismissedSessions.Contains('sess-cycle')) ($script:dismissedSessions | ConvertTo-Json -Compress) + +Write-Output "1..$script:n" +Write-Output "# pass $script:n fail $script:fail" +if ($script:fail -gt 0) { exit 1 } +exit 0 diff --git a/plugins/antianqi/mcode-island/scripts/test-substep-progress.mjs b/plugins/antianqi/mcode-island/scripts/test-substep-progress.mjs index c300fd8d..5fd1574e 100644 --- a/plugins/antianqi/mcode-island/scripts/test-substep-progress.mjs +++ b/plugins/antianqi/mcode-island/scripts/test-substep-progress.mjs @@ -15,7 +15,7 @@ import { readFileSync, existsSync, mkdtempSync, writeFileSync, rmSync, mkdirSync import { tmpdir } from 'node:os'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { execFileSync, spawn } from 'node:child_process'; +import { execFileSync, spawn, spawnSync } from 'node:child_process'; const HERE = dirname(fileURLToPath(import.meta.url)); const PLUGIN_ROOT = dirname(HERE); @@ -69,7 +69,7 @@ const PS_BIN = resolvePs(); // cannot leave fixtures behind for the next one to read. const TMP = mkdtempSync(join(tmpdir(), 'island-substep-')); -function runPs(script, extraEnv) { +function runPs(script, extraEnv, allowFail) { const file = join(TMP, `run-${Math.random().toString(36).slice(2)}.ps1`); // Force UTF-8 on both ends of the pipe. The console code page here is 936 // (GBK), so a middle dot in a script literal comes back as the two bytes @@ -80,17 +80,29 @@ function runPs(script, extraEnv) { // it PowerShell 5.1 reads the .ps1 as ANSI and mangles the literal. const preamble = '[Console]::OutputEncoding = [System.Text.Encoding]::UTF8\n'; writeFileSync(file, '\uFEFF' + preamble + script, 'utf8'); - const out = execFileSync(PS_BIN, ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', file], - { - encoding: 'buffer', - stdio: ['ignore', 'pipe', 'pipe'], - env: extraEnv ? { ...process.env, ...extraEnv } : process.env, - }); - let buf = out; + const r = spawnSync(PS_BIN, ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', file], { + encoding: 'buffer', + stdio: ['ignore', 'pipe', 'pipe'], + env: extraEnv ? { ...process.env, ...extraEnv } : process.env, + }); + // The child's stderr is kept: a harness that dies before its first + // check otherwise reports "0 passed", which reads like "there was + // nothing to test" rather than "the harness is broken". + const errText = (r.stderr || Buffer.alloc(0)).toString('utf8'); + if (r.status !== 0 && !allowFail) { + throw new Error(`PS harness failed (exit ${r.status}): ${errText || (r.stdout || '').toString('utf8')}`); + } + let buf = r.stdout || Buffer.alloc(0); if (buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf) { buf = buf.subarray(3); } - return buf.toString('utf8'); + let text = buf.toString('utf8'); + if (errText) { + // Tagged so the TAP parser ignores it, but it is still in the + // output the caller reads. + text += `\n# stderr: ${errText.replace(/\r?\n/g, ' | ')}`; + } + return text; } /** @@ -1270,6 +1282,44 @@ Write-Output ("ORIGIN=" + $t.Origin + " MODE=" + $t.Mode) } } +// --------------------------------------------------------------------------- +// 10. The multi-session tracker (v0.5.0) +// --------------------------------------------------------------------------- +// +// The tracker is a separate PowerShell harness: it assembles the scope +// the tracker lib expects (the detector's constants, Get-LastJsonLine, +// Test-IsAskUser, the store lock) against a FIXTURE session tree, and +// asserts the state machine, the entry rule, the title extraction, the +// persistence and the dismissal. It emits TAP-ish lines. +// +// It runs in its own process because the tracker lib keeps state in +// $script: variables, and dot-sourcing it into this process would mix +// the fixture's state with the detector's. +if (PS_BIN) { + const trackerSuite = join(PLUGIN_ROOT, 'scripts', 'test-session-tracker.ps1'); + check('the session tracker suite exists', existsSync(trackerSuite), `${trackerSuite} not found`); + + if (existsSync(trackerSuite)) { + const out = runPs(`& '${trackerSuite.replace(/'/g, "''")}'`, undefined, true); + const lines = out.split(/\r?\n/); + let okCount = 0; + let badCount = 0; + const bad = []; + for (const l of lines) { + if (/^ok \d+ - /.test(l)) { okCount++; } + else if (/^not ok \d+ - /.test(l)) { badCount++; bad.push(l.replace(/^not ok \d+ - /, '')); } + } + check('the session tracker suite passes', badCount === 0 && okCount > 0, + `${okCount} passed, ${badCount} failed: ${bad.join(' | ')}`); + for (const b of bad) { + console.log(` FAIL ${b}`); + } + // The count is pinned so a silently empty harness cannot pass. + check('the session tracker suite ran its full set of checks', okCount >= 20, + `only ${okCount} checks ran`); + } +} + // --------------------------------------------------------------------------- // summary // --------------------------------------------------------------------------- From 37652f02f3a168c6f913ce51f93bedf4d6fa9260 Mon Sep 17 00:00:00 2001 From: antianqi Date: Fri, 23 Oct 2026 14:44:27 +0800 Subject: [PATCH 02/15] feat(mcode-island): show the session list when the mouse rests on the pill Hovering the pill expands a list under it: one row per session, with a state dot (blue = running, amber = waiting for you), the session's first line, and its state. Clicking a row dismisses it and switches the CLI to that session. The list is drawn INSIDE the pill's own window, not in a second window. A separate top-level window was tried first and measured: it was created, positioned correctly, and hit-testable (WindowFromPoint returned it), yet it painted nothing -- of 1053 sampled pixels inside its rect, only 39 had the panel colour and the rest was whatever was behind it. The same code painted fine in isolation, so the problem was in the widget's context, not in the window. Drawing in the pill's own window sidesteps it entirely: the same rendering, compositing and hit-testing path the pill already uses, which is proven on this machine. The cost is that the window gets taller. Row 0's height is pinned to the pill's height, so the pill itself does not move when the list opens -- the list grows below it. And because the list is in the same window, the mouse can move from the pill into the list without ever leaving the window: no gap, no grace period for the cursor to fall into. The rows need three grid columns (dot / title / state label). Without them the children overlap and a long title covers the label. Clicking a row has to mark the event handled: the window carries the drag handler, and without that a click on a row would be read as the start of dragging the pill. Dismissing writes ack.signal, which the detector consumes. Switching runs `mcode --session ` in a new Windows Terminal tab, falling back to conhost where wt.exe is not on PATH. ## Test evidence Verified on the target machine by driving the real mouse: hovering the pill expands the list with the live sessions, and clicking a row writes ack.signal, removes the row, and opens the session. The list's presence is asserted by sampling pixels inside the window rect and checking the panel colour, not by reading the log: a window that claims to be visible but paints nothing is exactly the failure this feature hit, and a log line would have said "shown". The behavioral suite is unchanged by this commit (125 pass, 0 fail); the session tracker checks live in the previous commit. ## Design compliance no credentials : none added no network : none added no telemetry : none added no third-party svcs : no new dependencies cross-platform : no host-absolute paths introduced encoding : the file keeps its UTF-8 BOM visual change : the pill itself is untouched -- same size, same colours, same rendering. The list only appears while the mouse rests on it, and the window returns to its original height when it leaves. --- .../antianqi/mcode-island/mcode-island.ps1 | 277 +++++++++++++++++- 1 file changed, 276 insertions(+), 1 deletion(-) diff --git a/plugins/antianqi/mcode-island/mcode-island.ps1 b/plugins/antianqi/mcode-island/mcode-island.ps1 index 6c05cab0..bfef7ff4 100644 --- a/plugins/antianqi/mcode-island/mcode-island.ps1 +++ b/plugins/antianqi/mcode-island/mcode-island.ps1 @@ -211,7 +211,14 @@ $xaml = @' Topmost="True" ShowInTaskbar="False" ResizeMode="NoResize" WindowStartupLocation="Manual" UseLayoutRounding="True" SnapsToDevicePixels="True" Focusable="False" ShowActivated="False"> - + + + + + + + @@ -297,6 +304,23 @@ $xaml = @' VerticalAlignment="Center"/> + + + + + + '@ @@ -327,6 +351,12 @@ $progressShimmer = $window.FindName('ProgressShimmer') $progressShimmerTransform = $window.FindName('ProgressShimmerTransform') $elapsedText = $window.FindName('ElapsedText') $usage5hText = $window.FindName('Usage5hText') +# 多 session 列表(画在 pill 自己的窗口里) +$script:sessionRowsPanel = $window.FindName('SessionPanel') +$script:sessionRows = $window.FindName('SessionRows') +$script:sessionListRow = $window.FindName('SessionListRow') +$script:pillHeight = 70 +$script:listHideTimer = $null # 状态配色 $stateMap = @{ @@ -338,6 +368,220 @@ $stateMap = @{ error = @{ dot='#FFEF4444'; ring='#FFEF4444'; label='mcode · 出错'; icon='✕' } } +# --------------------------------------------------------------------------- +# 多 session 列表(v0.5.0) +# +# 鼠标停在灵动岛上时弹出一个列表:每个 session 一行,状态点 + 首条提问 + +# 运行中/待回复。点一行 = 消掉它(写 ack.signal,detector 收走)并把这个 +# session 切过去(mcode --session )。 +# +# 为什么是独立窗口而不是 WPF Popup: +# - Popup 是独立 HWND,不继承 owner 的 always-on-top。这个 pill 靠 +# Assert-Topmost 维持置顶,Popup 不在那套机制里,会被别的置顶窗口盖住。 +# - 独立窗口可以自己 Topmost=True / ShowActivated=False,接管完全在我们手里。 +# - pill 本体的 XAML 一个字都不用动 —— 它的视觉被反复调过,动它风险最大。 +# +# 代价:鼠标从 pill 移到列表窗口会离开 pill(两个 HWND),所以收起有一个 +# 宽限期,鼠标进列表窗口就取消。 +# --------------------------------------------------------------------------- +# --------------------------------------------------------------------------- +# 多 session 列表(v0.5.0) +# +# 鼠标停在灵动岛上时,在药丸下方展开一个列表:每个 session 一行,状态点 + +# 首条提问 + 运行中/待回复。点一行 = 消掉它(写 ack.signal,detector 收走) +# 并把这个 session 切过去(mcode --session )。 +# +# 为什么画在 pill 自己的窗口里,而不是另开一个窗口: +# 独立窗口方案实测过 —— 窗口能创建、位置正确、能被命中 +# (WindowFromPoint 返回它),却一个像素都不画:它自己矩形里 1053 个 +# 采样点只有 39 个是面板色,其余全是它背后的东西。隔离环境里同样的 +# 代码却能画出来,所以问题出在 widget 的上下文里。 +# 同一个窗口就没有这个问题:置顶、合成、命中测试全都走 pill 已经验证 +# 过的��一套。 +# +# 代价是窗口要变高。row 0 的高度锁死为药丸高度,所以展开时药丸本身一动 +# 不动,列表在它下面长出来。鼠标从药丸移到列表不用离开窗口,也没有那个 +# "两个窗口之间的空档"。 +# --------------------------------------------------------------------------- + +function Read-SessionsFile { + $file = Join-Path $configDir 'sessions.json' + if (!(Test-Path -LiteralPath $file)) { return @() } + try { + $raw = [System.IO.File]::ReadAllText($file) + $j = $raw | ConvertFrom-Json + return @($j.sessions) + } catch { + return @() + } +} + +function Clear-SessionRows { + if (-not $script:sessionRows) { return } + $script:sessionRows.Children.Clear() +} + +function Add-SessionRow([string]$id, [string]$title, [string]$phase) { + if (-not $script:sessionRows) { return } + $isRunning = ($phase -eq 'running') + $dotHex = if ($isRunning) { '#FF3B82F6' } else { '#FFF59E0B' } + $phaseText = if ($isRunning) { '运行中' } else { '待回复' } + + $grid = New-Object System.Windows.Controls.Grid + $grid.Margin = New-Object System.Windows.Thickness(0,1,0,1) + $grid.Cursor = [System.Windows.Input.Cursor]::Hand + + # 三列:状态点(自适应) | 标题(占满剩余) | 状态标签(自适应)。 + # + # 不给列定义的话三个子元素会叠在一起 —— 标题一长就压住右边的 + # "运行中/待回复"。标题列必须用 * 才能被裁剪(TextTrimming 只在有 + # 有限宽度时才生效)。 + $colDot = New-Object System.Windows.Controls.ColumnDefinition + $colDot.Width = New-Object System.Windows.GridLength(1, [System.Windows.GridUnitType]::Auto) + $colTitle = New-Object System.Windows.Controls.ColumnDefinition + $colTitle.Width = New-Object System.Windows.GridLength(1, [System.Windows.GridUnitType]::Star) + $colTag = New-Object System.Windows.Controls.ColumnDefinition + $colTag.Width = New-Object System.Windows.GridLength(1, [System.Windows.GridUnitType]::Auto) + $grid.ColumnDefinitions.Add($colDot) | Out-Null + $grid.ColumnDefinitions.Add($colTitle) | Out-Null + $grid.ColumnDefinitions.Add($colTag) | Out-Null + + $dot = New-Object System.Windows.Shapes.Ellipse + $dot.Width = 8; $dot.Height = 8 + $dot.Fill = (New-Object System.Windows.Media.SolidColorBrush ([System.Windows.Media.ColorConverter]::ConvertFromString($dotHex))) + $dot.VerticalAlignment = [System.Windows.VerticalAlignment]::Center + $dot.Margin = New-Object System.Windows.Thickness(0,0,9,0) + + $txt = New-Object System.Windows.Controls.TextBlock + $txt.Text = if ($title) { $title } else { $id } + $txt.Foreground = (New-Object System.Windows.Media.SolidColorBrush ([System.Windows.Media.ColorConverter]::ConvertFromString('#FFF5F5F7'))) + $txt.FontSize = 12 + $txt.TextTrimming = [System.Windows.TextTrimming]::CharacterEllipsis + $txt.VerticalAlignment = [System.Windows.VerticalAlignment]::Center + $txt.ToolTip = $txt.Text + + $tag = New-Object System.Windows.Controls.TextBlock + $tag.Text = $phaseText + $tag.Foreground = (New-Object System.Windows.Media.SolidColorBrush ([System.Windows.Media.ColorConverter]::ConvertFromString('#FF9CA3AF'))) + $tag.FontSize = 10 + $tag.HorizontalAlignment = [System.Windows.HorizontalAlignment]::Right + $tag.VerticalAlignment = [System.Windows.VerticalAlignment]::Center + $tag.Margin = New-Object System.Windows.Thickness(8,0,0,0) + + # 事件冒泡:只挂在子元素上,Grid 和 StackPanel 都不挂,避免一次点击 + # 触发两次。 + # + # Handled=true 是必须的:窗口上挂了拖拽处理器(MouseLeftButtonDown), + # 列表在同一个窗口里,不拦住的话点一行会被当成"开始拖药丸"。 + foreach ($el in @($dot, $txt, $tag)) { + $el.Add_MouseLeftButtonDown({ + $args[1].Handled = $true + Invoke-SessionClick $id + }.GetNewClosure()) + } + + $grid.Children.Add($dot) | Out-Null + $grid.Children.Add($txt) | Out-Null + $grid.Children.Add($tag) | Out-Null + [System.Windows.Controls.Grid]::SetColumn($dot, 0) + [System.Windows.Controls.Grid]::SetColumn($txt, 1) + [System.Windows.Controls.Grid]::SetColumn($tag, 2) + $script:sessionRows.Children.Add($grid) | Out-Null +} + +function Show-SessionList { + if (-not $script:sessionRows) { return } + # 取消待收起的定时器。 + # + # 这一步不能只放在 MouseEnter 里:展开有三个入口(MouseEnter、 + # MouseMove 兜底、手动调用),少一个就可能出现"展开后 300ms 立刻收起" + # 的闪烁 —— 展开把窗口撑高,收起又把高度改回去,窗口反复跳。 + if ($script:listHideTimer) { + try { $script:listHideTimer.Stop() } catch {} + $script:listHideTimer = $null + } + $items = Read-SessionsFile + if (-not $items -or $items.Count -eq 0) { Hide-SessionList; return } + Clear-SessionRows + # 运行中的排前面,其余按"最后一次动静"倒序 + $sorted = @($items | Sort-Object @{ Expression = { if ($_.phase -eq 'running') { 0 } else { 1 } } }, @{ Expression = { $_.sinceMs }; Descending = $true }) + foreach ($s in $sorted) { + Add-SessionRow ([string]$s.id) ([string]$s.title) ([string]$s.phase) + } + # 列表高度 = 行数 * 行高 + 上下 padding;再给药丸的投影留一点余量 + $listH = ($sorted.Count * 24) + 12 + $script:sessionRowsPanel.Visibility = [System.Windows.Visibility]::Visible + $script:sessionListRow.Height = New-Object System.Windows.GridLength($listH) + $window.Height = $script:pillHeight + $listH + 14 + Dbg ("session list shown: " + $sorted.Count + " winH=" + $window.Height + " listH=" + $listH) +} + +function Hide-SessionList { + if ($script:listHideTimer) { + try { $script:listHideTimer.Stop() } catch {} + $script:listHideTimer = $null + } + if ($script:sessionRowsPanel) { + $script:sessionRowsPanel.Visibility = [System.Windows.Visibility]::Collapsed + } + if ($script:sessionListRow) { + $script:sessionListRow.Height = New-Object System.Windows.GridLength(0) + } + if ($window) { $window.Height = $script:pillHeight } +} + +# 宽限期之后才收起。鼠标从药丸移到列表会经过一段"两个窗口都不是"的地带, +# 没有宽限期列表会在鼠标到达之前就消失。 +function Schedule-SessionListHide { + if (-not $script:sessionRowsPanel) { return } + if ($script:listHideTimer) { try { $script:listHideTimer.Stop() } catch {} } + $script:listHideTimer = New-Object System.Windows.Threading.DispatcherTimer + $script:listHideTimer.Interval = [TimeSpan]::FromMilliseconds(300) + $script:listHideTimer.Add_Tick({ + try { $script:listHideTimer.Stop() } catch {} + Hide-SessionList + }) + $script:listHideTimer.Start() +} + +# 点一行:消掉它 + 切过去。 +function Invoke-SessionClick([string]$id) { + if (-not $id) { return } + # 消掉:写 ack.signal,detector 收走(它会从列表里删掉这一条,并且 + # 在这个 session 跑完下一轮之前不再把它收回来)。 + try { + $ackFile = Join-Path $configDir 'ack.signal' + [System.IO.File]::WriteAllText($ackFile, $id, [System.Text.Encoding]::UTF8) + Dbg "session ack written: $id" + } catch { + Dbg "SESSION ACK FAIL: $($_.Exception.Message)" + } + Hide-SessionList + Start-SessionTerminal $id +} + +# 切到这个 session:mcode --session +# +# 优先 Windows Terminal(wt.exe);它不在 PATH 时退回 conhost 的 start。 +# mcode 是个 .ps1 包装器,cmd 认不出来,所以两条路都经由 powershell 跑。 +function Start-SessionTerminal([string]$id) { + $inner = "mcode --session $id" + try { + $wt = Get-Command wt.exe -ErrorAction SilentlyContinue + if ($wt) { + Start-Process -FilePath $wt.Source -ArgumentList @( + '-w', '0', 'new-tab', '--title', 'mcode', + 'powershell.exe', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', $inner) + } else { + Start-Process -FilePath 'cmd.exe' -ArgumentList @( + '/c', 'start', '', 'powershell.exe', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', $inner) + } + Dbg "session jumped: $id" + } catch { + Dbg "SESSION JUMP FAIL: $($_.Exception.Message)" + } +} + # 工具家族配色:working 态下用 family 色盖掉 state 色,一眼分辨"在读"和"在改"。 # 只覆盖 dot/ring(视觉识别),不动 stateText 文案。 # family 名与 detector 的 $TOOL_FAMILIES 值一一对应;缺 family 时回落到 state 色。 @@ -1039,6 +1283,13 @@ if ($cfg.x -lt 0) { } $window.Width = $cfg.width $window.Height = $cfg.height +# 药丸高度锚点。row 0 锁死成这个高度,所以展开 session 列表时窗口变高、 +# 药丸本身一动不动,列表在它下面长出来。 +$script:pillHeight = $window.Height +try { + $pillRow = $window.FindName('PillRow') + if ($pillRow) { $pillRow.Height = New-Object System.Windows.GridLength($script:pillHeight) } +} catch {} $window.Opacity = $cfg.opacity # 右键被禁用(用 CLI 'mcode-island pin' 替代,更稳) @@ -1107,6 +1358,15 @@ $window.Add_MouseLeftButtonDown({ try { $window.CaptureMouse() } catch {} }) $window.Add_MouseMove({ + # 列表没开、而鼠标就在药丸上:补开一次。 + # + # MouseEnter 只在"鼠标进入窗口"时触发。widget 恰好在光标底下启动的话 + # (启动后光标没动过),MouseEnter 永远不会来,列表就再也不会出现。 + # 拖拽中不弹(dragStart 非空)。 + if ($null -eq $script:dragStart -and $script:sessionRowsPanel -and + $script:sessionRowsPanel.Visibility -ne [System.Windows.Visibility]::Visible) { + try { Show-SessionList } catch { Dbg "SESSION LIST SHOW FAIL: $($_.Exception.Message)" } + } if ($null -eq $script:dragStart) { return } # The position is read EVERY event, not only before the threshold is # crossed: the window is moving, so a position read during an earlier @@ -1146,6 +1406,19 @@ $window.Add_MouseLeftButtonUp({ $script:didDrag = $false }) +# 多 session 列表的展开/收起。 +# +# 鼠标停在 pill 上 -> 展开;离开 -> 宽限期后收起。列表是独立窗口,鼠标从 +# pill 走到列表会经过一段"两个窗口都不在"的地带,所以要留宽限期,鼠标 +# 进列表窗口就取消。 +$window.Add_MouseEnter({ + if ($script:listHideTimer) { try { $script:listHideTimer.Stop() } catch {} ; $script:listHideTimer = $null } + try { Show-SessionList } catch { Dbg "SESSION LIST SHOW FAIL: $($_.Exception.Message)" } +}) +$window.Add_MouseLeave({ + Schedule-SessionListHide +}) + # FileSystemWatcher 监听 status.json(轮询版本,FSW 在 WPF 应用里经常抽风) $script:lastStatusMtime = $null @@ -1240,6 +1513,8 @@ $script:realQuit = $false $window.Add_Closing({ $args[1].Cancel = $true Dbg 'Closing event -> hide' + # pill 藏起来时列表也得跟着藏,否则鼠标一移开就剩一个空框在屏幕上。 + try { Hide-SessionList } catch {} try { $cur = Get-Content $script:configFile -Raw -Encoding UTF8 | ConvertFrom-Json $cur.x = $window.Left From 74641fbd2a8138c7d98535612b52ea2da7bb41db Mon Sep 17 00:00:00 2001 From: antianqi Date: Fri, 23 Oct 2026 17:35:01 +0800 Subject: [PATCH 03/15] feat(mcode-island): the session list reads like a message list The expanded list showed one line per session: a status dot, the first question, and the state. That is enough to recognise a session but not to tell what it is doing RIGHT NOW -- "the detector follows whichever session wrote last" is the whole reason this list exists. The layout now follows the expanded-state reference the user pointed at: two lines of text per session and a timestamp on the right. [dot] first question............. just now last thing said........... running --------------------------------------------- [dot] another question........... 41 min ago running ............ running What each piece costs: first line the session title (already there) second line the last thing said, or the tool being run right column the last activity time, and the state The second line is the one that carries information. Looking at the first question alone, a session that is thinking and a session that is stuck on one long tool look identical. Data plumbing The tracker already had the last message in memory -- Get-LastJsonLine reads it and Get-SessionPhase parses its content -- so the summary is free: no extra IO, no extra file reads. preview the text of the last message (agent's or user's) lastTool the tool name of the last message lastAtMs the last activity time (the log's mtime) Three rules for what a summary may be, because "the last thing said" is not the same as "the last thing in the file": - a toolResult's text is tool output (code, logs), not conversation. It never becomes a summary. - a toolCall has no text; the tool name stands in for it. - a toolResult has no toolCall in its content, but it DOES carry message.toolName. Without that fallback the second line was empty for every session whose last line was a tool result -- which is most of the time while an agent is working. The tracker stores the raw values and the widget does the formatting. Changing the layout must not require touching the detector. One existing bug fixed on the way The tracker only marked itself dirty when the STATE changed. The summary and the timestamp change without the state changing (an agent runs three tools in a row: still running, but the summary and the clock move). The in-memory entry was right and the file on disk was not, so the list rendered an empty second line and a frozen timestamp. Now any change to the content marks the file dirty. Validation - scripts/test-session-tracker.ps1 grew from 20 to 37 checks: the summary, the tool name, the timestamp, the file round-trip, the restart, and the same-state-but-changed-content case. - The behavioural suite: 133 pass, 0 fail, 1 skipped (platform) on Windows PowerShell 5.1, which is what CI runs. - Negative injection, 7 mutations, each one expected to turn a specific check red: N1 drop the toolResult exclusion -> "toolResult's text is not a summary" goes red N2 stop recording lastAtMs -> the two timestamp checks go red N3 stop restoring preview -> "the preview survives a restart" goes red N4 stop recording lastTool -> the two tool-name checks go red N5 make the summary always empty -> the four summary checks go red N6 revert the dirty check -> "a changed tool name with an unchanged state still reaches the file" goes red N7 drop the toolName fallback -> "a toolResult's tool name is recorded" goes red Every mutation was applied on the bytes, not on a decoded string: a text-level round trip through the wrong code page turned the file from 16132 into 12651 characters and made every anchor miss. - Live, on this machine: the deployed plugin was restarted, the mouse rested on the pill, and the screenshot shows the layout above -- three sessions, two lines each, "just now / 41 min ago / 15:39" on the right, "running" under it, a rule between the rows. The first row's second line is the agent's own last sentence; the second row's is "running grep". Test evidence - node scripts/test-substep-progress.mjs -> 133 pass, 0 fail, 1 skipped - scripts/test-session-tracker.ps1 -> 37 pass, 0 fail - 7 negative injections -> each turned its own check red; the suite was green again after every revert Design compliance - The pill itself is unchanged: same window, same width, same drag handling, same 400ms poll. The list still grows out of row 1, so the pill does not move while the list opens. - The click handler moved to the row's outermost element, so a click anywhere in the row dismisses that session and nothing fires twice. - No credentials, no network, no telemetry, no third-party service. The summary comes from the session log the detector already reads. - UTF-8 with a BOM, LF line endings: both scripts are dot-sourced or run by Windows PowerShell 5.1 on a zh-CN host. --- .../antianqi/mcode-island/mcode-island.ps1 | 162 ++++++++++++++---- .../scripts/lib/Session-Tracker.ps1 | 90 ++++++++++ .../scripts/test-session-tracker.ps1 | 90 ++++++++++ 3 files changed, 310 insertions(+), 32 deletions(-) diff --git a/plugins/antianqi/mcode-island/mcode-island.ps1 b/plugins/antianqi/mcode-island/mcode-island.ps1 index bfef7ff4..7d92f9e0 100644 --- a/plugins/antianqi/mcode-island/mcode-island.ps1 +++ b/plugins/antianqi/mcode-island/mcode-island.ps1 @@ -421,21 +421,59 @@ function Clear-SessionRows { $script:sessionRows.Children.Clear() } -function Add-SessionRow([string]$id, [string]$title, [string]$phase) { +# 列表里每一项的高度(含内边距和分隔线)。 +# +# 两行(标题 + 摘要)加上下各 7px 的内边距,约 48px。写成一个常量, +# 是因为 Show-SessionList 要用它算窗口高度,而这里的内容一改就得重算。 +# 实测校准:标题 12.5px 行高约 17 + 摘要 11px 行高约 15 + 行距 2 +# + 上下内边距 14 + 分隔线 1 = 49。留一点余量取 48。 +$script:sessionRowHeight = 48 + +# 相对时间:近的给"多久之前",远的给钟点。 +# +# 和小红书那种消息列表一个路子:60 分钟以内说"3 分钟前"(精确到分钟就 +# 够了),再早就给 HH:mm("3 小时前"不如"13:09"精确)。 +function Format-RelativeTime([int64]$tsMs) { + if ($tsMs -le 0) { return '' } + $diff = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() - $tsMs + if ($diff -lt 0) { $diff = 0 } + if ($diff -lt 60000) { return '刚刚' } + if ($diff -lt 3600000) { return ([int][math]::Floor($diff / 60000)).ToString() + ' 分钟前' } + $t = [DateTimeOffset]::FromUnixTimeMilliseconds($tsMs).ToLocalTime() + return $t.ToString('HH:mm') +} + +# 列表的一行。 +# +# 布局参考小红书的展开态:左边一个状态点,中间两行字(标题 + 摘要), +# 右边时间戳。 +# +# 摘要那行是它比原来多出来的东西:光看首条提问,分不清"正在跑"和"卡在 +# 某个工具上";看最后一句才知道它现在在说什么、要不要我回。 +# +# [●] 标题………………………… 3 分钟前 +# 摘要………………………… 运行中 +# +# 状态点跨两行、垂直居中;标题和摘要都在中间列,所以天然左对齐。 +function Add-SessionRow([string]$id, [string]$title, [string]$phase, [string]$preview, [string]$lastTool, [int64]$lastAtMs, [bool]$isLast) { if (-not $script:sessionRows) { return } $isRunning = ($phase -eq 'running') $dotHex = if ($isRunning) { '#FF3B82F6' } else { '#FFF59E0B' } $phaseText = if ($isRunning) { '运行中' } else { '待回复' } + # 第二行:正文优先。没有正文(最后一条恰好是 toolCall)就退到"正在 + # 调用的工具名",总比空着强。 + $subText = '' + if ($preview) { $subText = $preview } + elseif ($lastTool) { $subText = '执行 ' + $lastTool } + if ($subText.Length -gt 48) { $subText = $subText.Substring(0, 48) + '…' } + $grid = New-Object System.Windows.Controls.Grid - $grid.Margin = New-Object System.Windows.Thickness(0,1,0,1) $grid.Cursor = [System.Windows.Input.Cursor]::Hand - # 三列:状态点(自适应) | 标题(占满剩余) | 状态标签(自适应)。 + # 三列:状态点(自适应) | 文字(占满剩余) | 右侧时间(自适应)。 # - # 不给列定义的话三个子元素会叠在一起 —— 标题一长就压住右边的 - # "运行中/待回复"。标题列必须用 * 才能被裁剪(TextTrimming 只在有 - # 有限宽度时才生效)。 + # 中间列必须是 * 才能被裁剪(TextTrimming 只在有有限宽度时才生效)。 $colDot = New-Object System.Windows.Controls.ColumnDefinition $colDot.Width = New-Object System.Windows.GridLength(1, [System.Windows.GridUnitType]::Auto) $colTitle = New-Object System.Windows.Controls.ColumnDefinition @@ -446,8 +484,16 @@ function Add-SessionRow([string]$id, [string]$title, [string]$phase) { $grid.ColumnDefinitions.Add($colTitle) | Out-Null $grid.ColumnDefinitions.Add($colTag) | Out-Null + # 两行:标题 / 摘要。 + $rowTop = New-Object System.Windows.Controls.RowDefinition + $rowTop.Height = New-Object System.Windows.GridLength(1, [System.Windows.GridUnitType]::Auto) + $rowBot = New-Object System.Windows.Controls.RowDefinition + $rowBot.Height = New-Object System.Windows.GridLength(1, [System.Windows.GridUnitType]::Auto) + $grid.RowDefinitions.Add($rowTop) | Out-Null + $grid.RowDefinitions.Add($rowBot) | Out-Null + $dot = New-Object System.Windows.Shapes.Ellipse - $dot.Width = 8; $dot.Height = 8 + $dot.Width = 7; $dot.Height = 7 $dot.Fill = (New-Object System.Windows.Media.SolidColorBrush ([System.Windows.Media.ColorConverter]::ConvertFromString($dotHex))) $dot.VerticalAlignment = [System.Windows.VerticalAlignment]::Center $dot.Margin = New-Object System.Windows.Thickness(0,0,9,0) @@ -455,38 +501,84 @@ function Add-SessionRow([string]$id, [string]$title, [string]$phase) { $txt = New-Object System.Windows.Controls.TextBlock $txt.Text = if ($title) { $title } else { $id } $txt.Foreground = (New-Object System.Windows.Media.SolidColorBrush ([System.Windows.Media.ColorConverter]::ConvertFromString('#FFF5F5F7'))) - $txt.FontSize = 12 + $txt.FontSize = 12.5 $txt.TextTrimming = [System.Windows.TextTrimming]::CharacterEllipsis $txt.VerticalAlignment = [System.Windows.VerticalAlignment]::Center - $txt.ToolTip = $txt.Text + + $sub = New-Object System.Windows.Controls.TextBlock + $sub.Text = $subText + $sub.Foreground = (New-Object System.Windows.Media.SolidColorBrush ([System.Windows.Media.ColorConverter]::ConvertFromString('#FF8B93A1'))) + $sub.FontSize = 11 + $sub.TextTrimming = [System.Windows.TextTrimming]::CharacterEllipsis + $sub.VerticalAlignment = [System.Windows.VerticalAlignment]::Center + $sub.Margin = New-Object System.Windows.Thickness(0,2,0,0) + + # 右列:时间戳 + 状态文字,整块垂直居中于两行(和小红书那个时间戳的 + # 位置一样)。 + $right = New-Object System.Windows.Controls.StackPanel + $right.VerticalAlignment = [System.Windows.VerticalAlignment]::Center + $right.HorizontalAlignment = [System.Windows.HorizontalAlignment]::Right + $right.Margin = New-Object System.Windows.Thickness(8,0,0,0) + + $time = New-Object System.Windows.Controls.TextBlock + $time.Text = (Format-RelativeTime $lastAtMs) + $time.Foreground = (New-Object System.Windows.Media.SolidColorBrush ([System.Windows.Media.ColorConverter]::ConvertFromString('#FF9CA3AF'))) + $time.FontSize = 10 + $time.HorizontalAlignment = [System.Windows.HorizontalAlignment]::Right $tag = New-Object System.Windows.Controls.TextBlock $tag.Text = $phaseText - $tag.Foreground = (New-Object System.Windows.Media.SolidColorBrush ([System.Windows.Media.ColorConverter]::ConvertFromString('#FF9CA3AF'))) + $tag.Foreground = (New-Object System.Windows.Media.SolidColorBrush ([System.Windows.Media.ColorConverter]::ConvertFromString($dotHex))) $tag.FontSize = 10 $tag.HorizontalAlignment = [System.Windows.HorizontalAlignment]::Right - $tag.VerticalAlignment = [System.Windows.VerticalAlignment]::Center - $tag.Margin = New-Object System.Windows.Thickness(8,0,0,0) - # 事件冒泡:只挂在子元素上,Grid 和 StackPanel 都不挂,避免一次点击 - # 触发两次。 + $right.Children.Add($time) | Out-Null + $right.Children.Add($tag) | Out-Null + + $grid.Children.Add($dot) | Out-Null + $grid.Children.Add($txt) | Out-Null + $grid.Children.Add($right) | Out-Null + $grid.Children.Add($sub) | Out-Null + [System.Windows.Controls.Grid]::SetColumn($dot, 0) + [System.Windows.Controls.Grid]::SetColumn($txt, 1) + [System.Windows.Controls.Grid]::SetColumn($right, 2) + [System.Windows.Controls.Grid]::SetColumn($sub, 1) + [System.Windows.Controls.Grid]::SetRow($dot, 0) + [System.Windows.Controls.Grid]::SetRow($txt, 0) + [System.Windows.Controls.Grid]::SetRow($right, 0) + [System.Windows.Controls.Grid]::SetRow($sub, 1) + # 状态点和右列各跨两行:状态点在整块左边居中,时间戳在整块右边居中。 + $dot.SetValue([System.Windows.Controls.Grid]::RowSpanProperty, 2) + $right.SetValue([System.Windows.Controls.Grid]::RowSpanProperty, 2) + + # 项之间的分隔线。最后一项不带线(下面就是面板底边)。 + $border = New-Object System.Windows.Controls.Border + $border.BorderBrush = (New-Object System.Windows.Media.SolidColorBrush ([System.Windows.Media.ColorConverter]::ConvertFromString('#1FFFFFFF'))) + $border.BorderThickness = $(if ($isLast) { New-Object System.Windows.Thickness(0) } else { New-Object System.Windows.Thickness(0,0,0,1) }) + $border.Padding = New-Object System.Windows.Thickness(0,7,0,7) + $border.Child = $grid + + # 提示框:标题 + 摘要 + 状态 + 时间,一次说全。 + $tip = if ($title) { $title } else { $id } + if ($subText) { $tip = $tip + "`n" + $subText } + $rel = Format-RelativeTime $lastAtMs + if ($rel) { $tip = $tip + "`n" + $phaseText + ' · ' + $rel } else { $tip = $tip + "`n" + $phaseText } + $border.ToolTip = $tip + + # 点击挂在最外层的 Border 上,子元素不再挂: + # + # MouseLeftButtonDown 会冒泡。挂子元素时,Border + Grid + TextBlock 各挂 + # 一份就会一次点击触发多次消掉。现在只有最外层一个 handler,点整行任何 + # 位置都算点这一行。 # # Handled=true 是必须的:窗口上挂了拖拽处理器(MouseLeftButtonDown), # 列表在同一个窗口里,不拦住的话点一行会被当成"开始拖药丸"。 - foreach ($el in @($dot, $txt, $tag)) { - $el.Add_MouseLeftButtonDown({ - $args[1].Handled = $true - Invoke-SessionClick $id - }.GetNewClosure()) - } + $border.Add_MouseLeftButtonDown({ + $args[1].Handled = $true + Invoke-SessionClick $id + }.GetNewClosure()) - $grid.Children.Add($dot) | Out-Null - $grid.Children.Add($txt) | Out-Null - $grid.Children.Add($tag) | Out-Null - [System.Windows.Controls.Grid]::SetColumn($dot, 0) - [System.Windows.Controls.Grid]::SetColumn($txt, 1) - [System.Windows.Controls.Grid]::SetColumn($tag, 2) - $script:sessionRows.Children.Add($grid) | Out-Null + $script:sessionRows.Children.Add($border) | Out-Null } function Show-SessionList { @@ -503,13 +595,19 @@ function Show-SessionList { $items = Read-SessionsFile if (-not $items -or $items.Count -eq 0) { Hide-SessionList; return } Clear-SessionRows - # 运行中的排前面,其余按"最后一次动静"倒序 - $sorted = @($items | Sort-Object @{ Expression = { if ($_.phase -eq 'running') { 0 } else { 1 } } }, @{ Expression = { $_.sinceMs }; Descending = $true }) + # 运行中的排前面,其余按"最后一次动静"倒序。 + # + # 排序用 lastAtMs(最后活动时间),不是 sinceMs(进列表的时间): + # 进列表的时间只说明"我什么时候看见它",说明不了"它现在有多活跃"。 + $sorted = @($items | Sort-Object @{ Expression = { if ($_.phase -eq 'running') { 0 } else { 1 } } }, @{ Expression = { if ($_.lastAtMs) { [int64]$_.lastAtMs } else { [int64]$_.sinceMs } }; Descending = $true }) + $i = 0 foreach ($s in $sorted) { - Add-SessionRow ([string]$s.id) ([string]$s.title) ([string]$s.phase) + Add-SessionRow ([string]$s.id) ([string]$s.title) ([string]$s.phase) ([string]$s.preview) ([string]$s.lastTool) ([int64]$s.lastAtMs) ($i -lt ($sorted.Count - 1)) + $i++ } - # 列表高度 = 行数 * 行高 + 上下 padding;再给药丸的投影留一点余量 - $listH = ($sorted.Count * 24) + 12 + # 列表高度 = 行数 * 行高 + 上下 padding;再给药丸的投影留一点余量。 + # 行高是两项(标题 + 摘要)加内边距,不是原来单行的那 24。 + $listH = ($sorted.Count * $script:sessionRowHeight) + 12 $script:sessionRowsPanel.Visibility = [System.Windows.Visibility]::Visible $script:sessionListRow.Height = New-Object System.Windows.GridLength($listH) $window.Height = $script:pillHeight + $listH + 14 diff --git a/plugins/antianqi/mcode-island/scripts/lib/Session-Tracker.ps1 b/plugins/antianqi/mcode-island/scripts/lib/Session-Tracker.ps1 index c70e55ca..3743668f 100644 --- a/plugins/antianqi/mcode-island/scripts/lib/Session-Tracker.ps1 +++ b/plugins/antianqi/mcode-island/scripts/lib/Session-Tracker.ps1 @@ -188,6 +188,63 @@ function Get-SessionPhase($msg) { return 'running' } +# 最后一条消息的正文摘要(列表的第二行)。 +# +# 从同一条消息里取,零额外 IO:Get-LastJsonLine 已经把它读进内存了, +# Get-SessionPhase 也已经解析过 $m.content,只是没取正文。 +# +# 这里只负责"把原文取出来",不负责怎么排版:摘要的格式(截断长度、 +# "执行 " 这种前缀)归 widget 管,detector 只给原始数据。这样 +# 换一种排版不用动 detector。 +# +# 取什么,按"用户想看到什么"来定: +# +# assistant 的正文 -> agent 最后说了什么。这是"待回复"会话里最该看到的 +# 一句话:它到底说了什么、要我干什么。 +# user 的正文 -> 用户最后说了什么。 +# toolCall -> 只留工具名,widget 拼成"执行 xxx"。agent 正在干活 +# 时最后一条往往就是 toolCall,没有正文可显示,给个 +# 动作名比留空好。 +# toolResult -> 不取。它的正文是工具输出(代码、日志),不是对话 +# 内容,塞进摘要只会把列表变成日志窗口。 +function Get-SessionPreview($msg) { + if (-not $msg) { return '' } + $m = $msg.message + if (-not $m) { return '' } + $role = [string]$m.role + # toolResult 的正文是工具输出(代码、日志、文件内容),不是对话内容。 + # 当摘要用会把列表变成日志窗口,所以整条排除。 + if ($role -eq $S_TOOLRESULT) { return '' } + $text = '' + foreach ($c in @($m.content)) { + if ($c.type -eq 'text' -and $c.text -and -not $text) { $text = [string]$c.text; break } + } + if (-not $text) { return '' } + $text = $text -replace '(?s).*?', '' + $text = ($text -replace '\s+', ' ').Trim() + return $text +} + +# 最后一条消息里的工具名(toolCall)。 +# +# 和摘要分开放,因为它们的来源不同:摘要是正文,工具名只在最后一条 +# 恰好是 toolCall 时才有。widget 先用摘要,没有再用它兜底。 +# +# toolResult 消息也带工具名,但不在 content 里,而在 message.toolName +# 上。agent 跑完一个工具之后,最后一条往往正是 toolResult —— 实测下来 +# 那样 preview 是空的(工具输出不当摘要),content 里也没有 toolCall, +# 工具名就漏了,第二行会整个空着。所以这里要认 message.toolName。 +function Get-SessionLastTool($msg) { + if (-not $msg) { return '' } + $m = $msg.message + if (-not $m) { return '' } + foreach ($c in @($m.content)) { + if ($c.type -eq 'toolCall' -and $c.name) { return [string]$c.name } + } + if ($m.PSObject.Properties['toolName'] -and $m.toolName) { return [string]$m.toolName } + return '' +} + # 消掉一个 session(用户点了)。 # 记下被点掉时的 cycle:同一个 cycle 内不会再冒出来;agent 跑完一轮再 # 开始(waiting -> running)算新的一轮,那时它可以重新进来。 @@ -270,6 +327,14 @@ function Update-SessionTracker { Cycle = $cycle MTime = $g.MTime SinceMs = [int64]([DateTimeOffset]::new($g.MTime).ToUnixTimeMilliseconds()) + # 列表的第二行和右侧时间戳。 + # Preview = 最后一条消息的正文(agent 或用户说的) + # LastTool = 最后一条消息恰好是 toolCall 时的工具名 + # LastAtMs = 最后一次活动时间(日志 mtime)。不是"进列表的时间", + # 那是 SinceMs;列表右侧显示的是这个。 + Preview = (Get-SessionPreview $msg) + LastTool = (Get-SessionLastTool $msg) + LastAtMs = [int64]([DateTimeOffset]::new($g.MTime).ToUnixTimeMilliseconds()) Dir = $g.Dir } $script:sessionsDirty = $true @@ -290,6 +355,21 @@ function Update-SessionTracker { if ($entry.Phase -ne $phase) { $script:sessionsDirty = $true } $entry.Phase = $phase $entry.MTime = $g.MTime + # 第二行和右侧时间戳跟着最后一条消息走。 + # + # 变了就置 dirty。这里原来只看 Phase 变没变:agent 连着跑几个工具时 + # Phase 一直是 running,摘要和最后活动时间却在变,不置 dirty 的话 + # 磁盘上的 sessions.json 会一直停在旧值 —— 列表第二行空着、右侧的 + # 时间戳也不动。内存里是对的,落到文件上却丢了。 + $newPreview = (Get-SessionPreview $msg) + $newTool = (Get-SessionLastTool $msg) + $newAtMs = [int64]([DateTimeOffset]::new($g.MTime).ToUnixTimeMilliseconds()) + if ($entry.Preview -ne $newPreview -or $entry.LastTool -ne $newTool -or $entry.LastAtMs -ne $newAtMs) { + $script:sessionsDirty = $true + } + $entry.Preview = $newPreview + $entry.LastTool = $newTool + $entry.LastAtMs = $newAtMs if (-not $entry.Title) { $entry.Title = (Get-SessionTitle $g.Log) } } } @@ -311,6 +391,11 @@ function Write-SessionsFile { phase = $e.Phase sinceMs = $e.SinceMs cycle = $e.Cycle + # 列表第二行 / 右侧时间戳。缺字段的旧条目补空串,不要 null: + # widget 那边按字符串处理,null 会多一层判空。 + preview = $(if ($e.Preview) { [string]$e.Preview } else { '' }) + lastTool = $(if ($e.LastTool) { [string]$e.LastTool } else { '' }) + lastAtMs = $(if ($e.LastAtMs) { [int64]$e.LastAtMs } else { 0 }) } } $payload = [PSCustomObject]@{ @@ -348,6 +433,11 @@ function Restore-SessionTracker { Cycle = $(if ($s.cycle) { [int]$s.cycle } else { 1 }) MTime = [DateTime]::MinValue SinceMs = [int64]$s.sinceMs + # 第二行和右侧时间戳也要恢复:detector 重启到下一次刷新之间, + # widget 读的就是这个文件,不能白屏一行。 + Preview = $(if ($s.preview) { [string]$s.preview } else { '' }) + LastTool = $(if ($s.lastTool) { [string]$s.lastTool } else { '' }) + LastAtMs = $(if ($s.lastAtMs) { [int64]$s.lastAtMs } else { 0 }) Dir = '' } } diff --git a/plugins/antianqi/mcode-island/scripts/test-session-tracker.ps1 b/plugins/antianqi/mcode-island/scripts/test-session-tracker.ps1 index 6f4a88cc..46e6709f 100644 --- a/plugins/antianqi/mcode-island/scripts/test-session-tracker.ps1 +++ b/plugins/antianqi/mcode-island/scripts/test-session-tracker.ps1 @@ -93,6 +93,12 @@ function Line-ToolCall([string]$tool) { function Line-ToolResult { return '{"message":{"role":"toolResult","content":[{"type":"text","text":"ok"}],"timestamp":4}}' } +# A toolResult that names its tool. The real log puts the tool name on +# message.toolName, not in the content array -- and a toolResult is what +# the last line usually is once a tool has run. +function Line-ToolResultNamed([string]$tool) { + return '{"message":{"role":"toolResult","toolName":"' + $tool + '","content":[{"type":"text","text":"ok"}],"timestamp":4}}' +} # --- 1. 状态机 ----------------------------------------------------------- $fTool = New-Session 'sess-running' @((Line-ToolCall 'Bash')) @@ -172,6 +178,90 @@ Update-SessionTracker Check '新一轮开始后 session 重新进列表' ($script:trackedSessions.Contains('sess-cycle')) '没回来' Check '新一轮清掉了 dismissed 记录' (-not $script:dismissedSessions.Contains('sess-cycle')) ($script:dismissedSessions | ConvertTo-Json -Compress) +# --- 8. 摘要与时间戳:列表的第二行和右侧那一列 --------------------------- +# +# 布局参考小红书的展开态之后,列表的一行有两行字和一个时间戳: +# 第一行是首条提问,第二行是"最后说了什么",右边是最后活动时间。 +# +# 这三条断言盯的就是那条数据链: +# preview = 最后一条消息的正文(agent 或用户说的) +# lastTool = 最后一条消息恰好是 toolCall 时的工具名 +# lastAtMs = 最后一次活动时间(日志 mtime) +# +# 少任何一条,第二行就会空着,或者时间戳变成"进列表的时间"(那是很久 +# 以前,排序和显示都不对)。 + +$fPrev = New-Session 'sess-preview' @((Line-Assistant '我改完了,你看一下')) +Update-SessionTracker +$ePrev = $script:trackedSessions['sess-preview'] +Check 'assistant 的正文进 preview' ($ePrev.Preview -eq '我改完了,你看一下') ("得到: '" + $ePrev.Preview + "'") +Check '最后活动时间有值' ($ePrev.LastAtMs -gt 0) ("得到: " + $ePrev.LastAtMs) +Check '最后活动时间接近现在' ([math]::Abs(([DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() - [int64]$ePrev.LastAtMs)) -lt 60000) ("得到: " + $ePrev.LastAtMs) + +# toolCall 结尾:没有正文,preview 空,但工具名要留着给 widget 兜底。 +$fToolPrev = New-Session 'sess-toolpreview' @((Line-ToolCall 'Bash')) +Update-SessionTracker +$eToolPrev = $script:trackedSessions['sess-toolpreview'] +Check 'toolCall 结尾时 preview 为空' ([string]::IsNullOrEmpty($eToolPrev.Preview)) ("得到: '" + $eToolPrev.Preview + "'") +Check 'toolCall 结尾时工具名被记下来' ($eToolPrev.LastTool -eq 'Bash') ("得到: '" + $eToolPrev.LastTool + "'") + +# toolResult 结尾:正文是工具输出(代码、日志),不当摘要。 +$fResPrev = New-Session 'sess-resultpreview' @((Line-ToolResult)) +Update-SessionTracker +$eResPrev = $script:trackedSessions['sess-resultpreview'] +Check 'toolResult 的正文不进 preview' ([string]::IsNullOrEmpty($eResPrev.Preview)) ("得到: '" + $eResPrev.Preview + "'") + +# toolResult 带 toolName:工具名在 message.toolName 上,不在 content 里。 +# agent 跑完工具后最后一条往往就是 toolResult,这时 preview 是空的, +# 工具名是唯一能让第二行不空的东西。 +$fResNamed = New-Session 'sess-resultnamed' @((Line-ToolResultNamed 'Bash')) +Update-SessionTracker +$eResNamed = $script:trackedSessions['sess-resultnamed'] +Check 'toolResult 的工具名被记下来' ($eResNamed.LastTool -eq 'Bash') ("得到: '" + $eResNamed.LastTool + "'") +Check 'toolResult 的正文仍然不进 preview' ([string]::IsNullOrEmpty($eResNamed.Preview)) ("得到: '" + $eResNamed.Preview + "'") + +# 换了最后一条消息,preview 必须跟着换(不是只在新 session 时取一次)。 +[System.IO.File]::WriteAllText($fPrev, ((Line-Assistant '改完了' ) + "`n"), (New-Object System.Text.UTF8Encoding($false))) +Update-SessionTracker +Check '最后一条消息变了 preview 跟着变' ($script:trackedSessions['sess-preview'].Preview -eq '改完了') ("得到: '" + $script:trackedSessions['sess-preview'].Preview + "'") + +# 落盘:三个字段都得在文件里,widget 只读这个文件。 +Write-SessionsFile -Force +$saved2 = Get-Content (Join-Path $configDir 'sessions.json') -Raw | ConvertFrom-Json +$row2 = @($saved2.sessions | Where-Object { $_.id -eq 'sess-preview' })[0] +Check 'sessions.json 里有一行' ($null -ne $row2) '没有 sess-preview 这一行' +Check 'sessions.json 带 preview' ($null -ne $row2 -and $row2.preview -eq '改完了') ("得到: '" + $row2.preview + "'") +Check 'sessions.json 带 lastTool' ($null -ne $row2 -and $null -ne $row2.lastTool) ("得到: '" + $row2.lastTool + "'") +Check 'sessions.json 带 lastAtMs' ($null -ne $row2 -and [int64]$row2.lastAtMs -gt 0) ("得到: " + $row2.lastAtMs) + +# 重启恢复:detector 重启到下一次刷新之间,widget 读的就是这个文件, +# 第二行不能白屏。 +$script:trackedSessions = [ordered]@{} +Restore-SessionTracker +$eRestored = $script:trackedSessions['sess-preview'] +Check '重启后 preview 恢复了' ($eRestored.Preview -eq '改完了') ("得到: '" + $eRestored.Preview + "'") +Check '重启后 lastAtMs 恢复了' ([int64]$eRestored.LastAtMs -gt 0) ("得到: " + $eRestored.LastAtMs) + +# Phase 没变但内容变了,也要落盘。 +# +# agent 连着跑两个工具时 Phase 一直是 running,摘要和最后活动时间却在变。 +# 只看 Phase 变没变来决定写不写盘,磁盘上的 sessions.json 就会一直停在 +# 旧值:列表第二行空着、右侧的时间戳不动。内存里是对的,落到文件上丢了。 +$fSame = New-Session 'sess-samephase' @((Line-ToolCall 'Bash')) +Update-SessionTracker +Write-SessionsFile -Force +$beforeSame = Get-Content (Join-Path $configDir 'sessions.json') -Raw | ConvertFrom-Json +$rowBefore = @($beforeSame.sessions | Where-Object { $_.id -eq 'sess-samephase' })[0] +Check '同 Phase 的 session 带上了工具名' ($rowBefore.lastTool -eq 'Bash') ("得到: '" + $rowBefore.lastTool + "'") + +# 换一个工具名。Phase 仍然是 running(toolCall -> toolCall)。 +[System.IO.File]::WriteAllText($fSame, ((Line-ToolCall 'Read') + "`n"), (New-Object System.Text.UTF8Encoding($false))) +Update-SessionTracker +Write-SessionsFile # 故意不加 -Force: 该由 dirty 标志决定写不写 +$afterSame = Get-Content (Join-Path $configDir 'sessions.json') -Raw | ConvertFrom-Json +$rowAfter = @($afterSame.sessions | Where-Object { $_.id -eq 'sess-samephase' })[0] +Check 'Phase 没变但工具名变了也要落盘' ($rowAfter.lastTool -eq 'Read') ("得到: '" + $rowAfter.lastTool + "'") + Write-Output "1..$script:n" Write-Output "# pass $script:n fail $script:fail" if ($script:fail -gt 0) { exit 1 } From 71436c8ba5f445d184a95d4b8ef1fa28893fed4d Mon Sep 17 00:00:00 2001 From: antianqi Date: Fri, 23 Oct 2026 18:03:59 +0800 Subject: [PATCH 04/15] feat(mcode-island): the session list shows the real session title The list's first line was the first question of the session, cut at 40 characters. That is not a name: half the sessions start with a URL, and the rest start with a sentence that means nothing once it is cut ("go on and finish the capability list panel. first look at where the static scanner stands."). The runtime already keeps a title for every session: ~/.minimax/v2/sqlite/runtime-state.sqlite local_runtime_sessions.title 413 of the 417 sessions have one, and they are distilled subject lines ("fix the MCode 0.6.13 update failure", "what the PR 62 review says", "how mcode's miniapp works"). That is what the session is called. Reading it PowerShell has no SQLite reader and the plugin ships no binaries (the Skill-only, zero-npm-dependency rule). So the read borrows a node that is already on the machine: - node:sqlite is a BUILT-IN module of Node 22.5+, not a dependency; - the CLI install ships one: ~/.minimax-code/runtime/node-*/node.exe; - the desktop app is Electron, and ELECTRON_RUN_AS_NODE=1 turns it into a node (verified: node 24.18.0, node:sqlite available). The runtime is looked up in that order: node on PATH, then the CLI's own, then the desktop app as node. If none of them works, the whole feature is off and the list falls back to the first question -- exactly what it did before. The same shape as scripts/lib/Get-5hUsage.ps1: an optional lib, and when the lib is not there the chain is simply closed. The read is read-only and does not touch the app's data. It runs once a minute, not once a tick: the titles barely change, and opening a 1.9 GB database is not something to do every second. Measured on this machine: 391 ms for the first read, then cached. Two things that had to be right - The temporary script is written as .cjs. A package.json with "type": "module" (there is one in the user profile) makes node load a .js file as an ES module, and require() then fails with "require is not defined in ES module scope". - The database path is the second argv, not the first: argv[1] is the script's own path. Fallback The runtime title is preferred; the first question stays the fallback. The fallback is not a formality: the runtime keeps no title for four sessions, and a machine without a usable node keeps none at all. Validation - scripts/test-session-tracker.ps1 grew from 37 to 44 checks. The new ones build a REAL sqlite database with node:sqlite (a temporary one, pointed at through MCODE_RUNTIME_STATE_DB) and assert: the title comes back, a session that is not in the database has no title, an empty title stays empty, the list prefers the runtime title over the first question, and both fallbacks land on the first question. - The behavioural suite: 133 pass, 0 fail, 1 skipped (platform). - Negative injection, 10 mutations, each expected to turn a specific check red. Three of them are new: N8 the display name stops asking for the runtime title -> "the list prefers the runtime title" goes red N9 the fallback to the first question is removed -> "no runtime title falls back to the first question" and "a missing database falls back to the first question" go red N10 the title map is never read -> "the title comes back from the runtime database" goes red The seven earlier ones (preview, timestamp, tool name, the dirty check) still hold. - Live, on this machine: the list now reads "look at the new reply on MiniMax-Code-Plugins PR 62" "how mcode's miniapp works" where it used to read a cut-off first question. Test evidence - node scripts/test-substep-progress.mjs -> 133 pass, 0 fail, 1 skipped - scripts/test-session-tracker.ps1 -> 44 pass, 0 fail - 10 negative injections -> each turned its own check red; the suite was green again after every revert Design compliance - The plugin still ships no binaries and no npm dependencies. node:sqlite is built into the node it borrows; nothing is bundled. - No credentials, no network, no telemetry, no third-party service. The title is read from a local database the host already keeps, and the read is read-only. - The feature degrades to the previous behaviour instead of failing: no node, no database, no title in the database, a locked database, a timeout -- every one of those leaves the list exactly as it was. - UTF-8 with a BOM, LF line endings: both scripts are dot-sourced or run by Windows PowerShell 5.1 on a zh-CN host. --- .../scripts/lib/Runtime-Titles.ps1 | 157 ++++++++++++++++++ .../scripts/lib/Session-Tracker.ps1 | 35 +++- .../scripts/test-session-tracker.ps1 | 67 ++++++++ 3 files changed, 258 insertions(+), 1 deletion(-) create mode 100644 plugins/antianqi/mcode-island/scripts/lib/Runtime-Titles.ps1 diff --git a/plugins/antianqi/mcode-island/scripts/lib/Runtime-Titles.ps1 b/plugins/antianqi/mcode-island/scripts/lib/Runtime-Titles.ps1 new file mode 100644 index 00000000..29b80f17 --- /dev/null +++ b/plugins/antianqi/mcode-island/scripts/lib/Runtime-Titles.ps1 @@ -0,0 +1,157 @@ +# --------------------------------------------------------------------------- +# 会话标题(运行时 SQLite) +# +# 灵动岛的 session 列表第一行想显示"会话标题",而不是"首条提问的前 40 个 +# 字"。首条提问常常是一句长话、半个 URL、或者"继续收尾能力清单面板这轮 +# 的活。先看静态扫描器现在的状态。" —— 截断之后既不好看,也认不出是哪个 +# 会话。 +# +# 真正的标题在 MiniMax Code 运行时的 SQLite 里: +# +# ~/.minimax/v2/sqlite/runtime-state.sqlite +# 表 local_runtime_sessions,列 title +# +# 那 417 个会话里有 413 个带标题,而且是提炼过的主题句("修复 MCode +# 0.6.13 更新失败"这种),不是首条消息的截断。 +# +# # 为什么不是直接读 +# +# PowerShell 没有内置的 SQLite 读取器,而插件不能带二进制依赖(Skill-only、 +# 零 npm 依赖是硬约束)。所以这里借一个已经在机器上的 node: +# +# - node:sqlite 是 Node 22.5+ 的**内置**模块,不是 npm 依赖; +# - CLI 安装自带 node(~/.minimax-code/runtime/node-/node.exe); +# - 桌面端 app 是 Electron,ELECTRON_RUN_AS_NODE=1 之后它就是 node。 +# +# 找不到任何能用的运行时,整个功能就关掉:列表退回"首条提问",也就是 +# 这个能力上线之前的样子。和 scripts/lib/Get-5hUsage.ps1 同一个套路 —— +# lib 不在,链路关闭,detector 一行都不用改。 +# +# # 只读 +# +# 只读打开,不写、不建索引、不迁移。应用自己的锁和 WAL 由 SQLite 处理, +# 和应用同时开着也不冲突(实测过)。 +# --------------------------------------------------------------------------- + +# 查到的标题:session id -> 标题 +$script:runtimeTitles = @{} +# 标题的缓存时间戳。标题很少变,60s 查一次足够。 +$script:runtimeTitlesAt = [DateTime]::MinValue +# 标题的缓存有效期 +$script:RUNTIME_TITLES_TTL_MS = 60 * 1000 +# 节点脚本跑多久算超时。1.9GB 的库打开要读页,1.5s 足够;超了就当没有。 +$script:RUNTIME_TITLES_TIMEOUT_MS = 1500 + +# 找一个能用的 node。 +# +# 顺序有讲究: +# 1. PATH 上的 node —— 开发者机器上最常见的 +# 2. CLI 自带的 node —— ~/.minimax-code/runtime/node-*/node.exe +# 3. 桌面端 app 当 node 用 —— ELECTRON_RUN_AS_NODE=1 +# +# 第 3 条是给上架版用的:桌面端用户不一定装了 node,但一定跑着 app。 +function Get-NodeCommand { + $c = Get-Command node -ErrorAction SilentlyContinue + if ($c) { return @{ Exe = $c.Source; Env = $null } } + + # CLI 自带:~/.minimax-code/runtime/node-vXX.Y.Z-win-x64/node.exe + $runtimeRoot = Join-Path $env:USERPROFILE '.minimax-code\runtime' + if (Test-Path -LiteralPath $runtimeRoot) { + $nodeExe = Get-ChildItem -LiteralPath $runtimeRoot -Recurse -Depth 2 -File -Filter 'node.exe' -ErrorAction SilentlyContinue | + Sort-Object LastWriteTime -Descending | Select-Object -First 1 + if ($nodeExe) { return @{ Exe = $nodeExe.FullName; Env = $null } } + } + + # 桌面端 app:ELECTRON_RUN_AS_NODE=1 之后 Electron 就是 node。 + # + # 从已经在跑的进程里拿 exe 路径,比去 Program Files 里猜安装位置可靠: + # 装在哪、怎么升级都不会失配。 + $names = @('MiniMax Inside Code', 'MiniMax Code', 'Mavis') + foreach ($n in $names) { + $proc = Get-Process -Name $n -ErrorAction SilentlyContinue | Where-Object { $_.Path } | Select-Object -First 1 + if ($proc) { return @{ Exe = $proc.Path; Env = 'ELECTRON_RUN_AS_NODE=1' } } + } + return $null +} + +# 用 node 读一次标题表。 +# +# 返回 @{ id -> title },读不到就返回空表。**任何失败都只是少一个标题**, +# 不该把 detector 带崩:所以这里只吞异常,不往上抛。 +function Read-RuntimeTitles { + $result = @{} + # MCODE_RUNTIME_STATE_DB 可以指向别的库。测试要用一个自己建的临时库, + # 不然就只能读开发机上那个 1.9GB 的真库。 + $db = $env:MCODE_RUNTIME_STATE_DB + if (-not $db) { $db = Join-Path $env:USERPROFILE '.minimax\v2\sqlite\runtime-state.sqlite' } + if (-not (Test-Path -LiteralPath $db)) { return $result } + + $node = Get-NodeCommand + if (-not $node) { return $result } + + # 内联脚本:node:sqlite 是内置模块,只读打开,把 id -> title 打成 JSON。 + # + # 全表取而不是按 id 取:表只有几百行,一次读完比拼 IN 子句省事得多, + # 而且以后多了字段也不用改。 + # + # argv[1] 是脚本自己的路径,argv[2] 才是传进来的库路径。 + # + # 扩展名必须是 .cjs:用户目录下的 package.json 里可能有 "type": "module", + # 那会让 .js 按 ES module 加载,require() 直接报 "require is not defined"。 + $js = @' +const {DatabaseSync} = require("node:sqlite"); +const db = new DatabaseSync(process.argv[2], {readOnly: true}); +const out = {}; +try { + for (const r of db.prepare("SELECT session_id, title FROM local_runtime_sessions").all()) { + if (r && r.title) out[String(r.session_id)] = String(r.title); + } +} finally { db.close(); } +process.stdout.write(JSON.stringify(out)); +'@ + + $tmp = Join-Path $env:TEMP ('island-titles-' + [guid]::NewGuid().ToString('N').Substring(0, 8) + '.cjs') + try { + [System.IO.File]::WriteAllText($tmp, $js, (New-Object System.Text.UTF8Encoding($false))) + $psi = New-Object System.Diagnostics.ProcessStartInfo + $psi.FileName = $node.Exe + $psi.Arguments = '"' + $tmp + '" "' + $db + '"' + $psi.UseShellExecute = $false + $psi.RedirectStandardOutput = $true + $psi.RedirectStandardError = $true + $psi.CreateNoWindow = $true + if ($node.Env) { $psi.EnvironmentVariables['ELECTRON_RUN_AS_NODE'] = '1' } + $proc = [System.Diagnostics.Process]::Start($psi) + $out = $proc.StandardOutput.ReadToEnd() + $proc.StandardError.ReadToEnd() | Out-Null + if (-not $proc.WaitForExit($script:RUNTIME_TITLES_TIMEOUT_MS)) { + try { $proc.Kill() } catch {} + return $result + } + if ($proc.ExitCode -ne 0) { return $result } + if (-not $out) { return $result } + $parsed = $out | ConvertFrom-Json + foreach ($p in $parsed.PSObject.Properties) { $result[$p.Name] = [string]$p.Value } + } catch { + # 读不到就没有标题,列表退回首条提问。 + } finally { + if (Test-Path -LiteralPath $tmp) { + try { [System.IO.File]::Delete($tmp) } catch {} + } + } + return $result +} + +# 取某个 session 的标题。 +# +# 有缓存:标题很少变,60s 内直接用旧的。 +function Get-RuntimeSessionTitle([string]$id) { + if (-not $id) { return '' } + $now = Get-Date + if (($now - $script:runtimeTitlesAt).TotalMilliseconds -ge $script:RUNTIME_TITLES_TTL_MS) { + $script:runtimeTitles = Read-RuntimeTitles + $script:runtimeTitlesAt = $now + } + if ($script:runtimeTitles.ContainsKey($id)) { return [string]$script:runtimeTitles[$id] } + return '' +} diff --git a/plugins/antianqi/mcode-island/scripts/lib/Session-Tracker.ps1 b/plugins/antianqi/mcode-island/scripts/lib/Session-Tracker.ps1 index 3743668f..509fb139 100644 --- a/plugins/antianqi/mcode-island/scripts/lib/Session-Tracker.ps1 +++ b/plugins/antianqi/mcode-island/scripts/lib/Session-Tracker.ps1 @@ -37,6 +37,16 @@ $script:lastGroups = @{} # 运行期间完全靠状态机,不看时间。 $script:SEED_WINDOW_MS = 30 * 60 * 1000 +# 会话标题(运行时的 SQLite)。 +# +# lib 不在时整条链路关闭,列表退回"首条提问"—— 也就是这个能力上线之前的 +# 样子。跟 scripts/lib/Get-5hUsage.ps1 同一个套路:可选能力,可有可无。 +$script:runtimeTitlesAvailable = $false +if (Test-Path (Join-Path $PSScriptRoot 'Runtime-Titles.ps1')) { + . (Join-Path $PSScriptRoot 'Runtime-Titles.ps1') + $script:runtimeTitlesAvailable = $true +} + # 活动窗口:日志刚被写过,session 才算"有活动"。 # # 这个窗口只决定"能不能进列表",不决定进列表之后显示什么: @@ -245,6 +255,19 @@ function Get-SessionLastTool($msg) { return '' } +# 这个 session 在列表里显示的名字。 +# +# 优先用运行时的会话标题(SQLite 里那个提炼过的主题句:"修复 MCode 0.6.13 +# 更新失败")。拿不到就用首条提问 —— 首条提问常常是半个 URL 或者一句 +# 长话,截断之后既不好看,也认不出是哪个会话。 +function Get-SessionDisplayTitle([string]$id, [string]$log) { + if ($script:runtimeTitlesAvailable) { + $t = Get-RuntimeSessionTitle $id + if ($t) { return $t } + } + return (Get-SessionTitle $log) +} + # 消掉一个 session(用户点了)。 # 记下被点掉时的 cycle:同一个 cycle 内不会再冒出来;agent 跑完一轮再 # 开始(waiting -> running)算新的一轮,那时它可以重新进来。 @@ -322,7 +345,7 @@ function Update-SessionTracker { } $script:trackedSessions[$id] = @{ Id = $id - Title = (Get-SessionTitle $g.Log) + Title = (Get-SessionDisplayTitle $id $g.Log) Phase = $phase Cycle = $cycle MTime = $g.MTime @@ -370,6 +393,16 @@ function Update-SessionTracker { $entry.Preview = $newPreview $entry.LastTool = $newTool $entry.LastAtMs = $newAtMs + # 运行时标题可能比列表出现得晚(node 装好了、库能读了)。所以每次更新 + # 都再问一次,拿到更好的名字就升级。Get-RuntimeSessionTitle 内部有 60s + # 缓存,所以每秒问一次并不会每秒起进程。 + if ($script:runtimeTitlesAvailable) { + $rt = Get-RuntimeSessionTitle $id + if ($rt -and $rt -ne $entry.Title) { + $entry.Title = $rt + $script:sessionsDirty = $true + } + } if (-not $entry.Title) { $entry.Title = (Get-SessionTitle $g.Log) } } } diff --git a/plugins/antianqi/mcode-island/scripts/test-session-tracker.ps1 b/plugins/antianqi/mcode-island/scripts/test-session-tracker.ps1 index 46e6709f..e099acea 100644 --- a/plugins/antianqi/mcode-island/scripts/test-session-tracker.ps1 +++ b/plugins/antianqi/mcode-island/scripts/test-session-tracker.ps1 @@ -262,6 +262,73 @@ $afterSame = Get-Content (Join-Path $configDir 'sessions.json') -Raw | ConvertFr $rowAfter = @($afterSame.sessions | Where-Object { $_.id -eq 'sess-samephase' })[0] Check 'Phase 没变但工具名变了也要落盘' ($rowAfter.lastTool -eq 'Read') ("得到: '" + $rowAfter.lastTool + "'") +# --- 9. 会话标题:运行时 SQLite 优先,首条提问兜底 ------------------------ +# +# 列表第一行要显示"会话标题"。真正的标题在运行时的 SQLite 里 +# (local_runtime_sessions.title),是提炼过的主题句;首条提问常常是半个 +# URL 或者一句长话,截断之后既不好看也认不出是哪个会话。 +# +# 这里用**自建的临时库**验证,不碰开发机上那个 1.9GB 的真库: +# MCODE_RUNTIME_STATE_DB 可以指向别的库。 +# +# 需要 node:sqlite(Node 22.5+ 内置)。没有 node 的环境整节跳过, +# 不是静默通过 —— 跳过要在摘要里看得见。 +$nodeExe = (Get-Command node -ErrorAction SilentlyContinue) +$sqliteOk = $false +if ($nodeExe) { + $probe = Join-Path $fixture 'sqlite-probe.cjs' + [System.IO.File]::WriteAllText($probe, 'require("node:sqlite");', (New-Object System.Text.UTF8Encoding($false))) + try { + $null = & $nodeExe.Source $probe 2>&1 + $sqliteOk = ($LASTEXITCODE -eq 0) + } catch { $sqliteOk = $false } +} + +if (-not $sqliteOk) { + Write-Output "# skip the runtime-title checks: node:sqlite is not available here" +} else { + # 建一个只有一条标题的临时库。 + $tmpDb = Join-Path $fixture 'runtime-state.sqlite' + $build = Join-Path $fixture 'build-db.cjs' + $buildJs = @' +const {DatabaseSync} = require("node:sqlite"); +const db = new DatabaseSync(process.argv[2]); +db.exec("CREATE TABLE local_runtime_sessions (session_id TEXT, title TEXT)"); +const ins = db.prepare("INSERT INTO local_runtime_sessions (session_id, title) VALUES (?, ?)"); +ins.run("sess-titled", "这个会话有一个像样的名字"); +ins.run("sess-untitled", null); +db.close(); +'@ + [System.IO.File]::WriteAllText($build, $buildJs, (New-Object System.Text.UTF8Encoding($false))) + & $nodeExe.Source $build $tmpDb | Out-Null + Check '临时库建好了' (Test-Path -LiteralPath $tmpDb) ('没有生成: ' + $tmpDb) + + # 指过去,并强制下一次读缓存真的去读库。 + $env:MCODE_RUNTIME_STATE_DB = $tmpDb + $script:runtimeTitlesAt = [DateTime]::MinValue + $script:runtimeTitles = @{} + + Check '能从运行时库取到标题' ((Get-RuntimeSessionTitle 'sess-titled') -eq '这个会话有一个像样的名字') ("得到: '" + (Get-RuntimeSessionTitle 'sess-titled') + "'") + Check '运行时库里没有的会话没有标题' ([string]::IsNullOrEmpty((Get-RuntimeSessionTitle 'sess-nope'))) '不该有标题' + Check '标题为空的会话返回空' ([string]::IsNullOrEmpty((Get-RuntimeSessionTitle 'sess-untitled'))) '不该有标题' + + # 列表里显示的名字:运行时标题优先 + $fTitled = New-Session 'sess-titled' @((Line-User '首条提问是另外一句话')) + Check '显示用运行时标题而不是首条提问' ((Get-SessionDisplayTitle 'sess-titled' $fTitled) -eq '这个会话有一个像样的名字') ("得到: '" + (Get-SessionDisplayTitle 'sess-titled' $fTitled) + "'") + + # 运行时没有标题时,退回首条提问 + $fPlain = New-Session 'sess-plain' @((Line-User '首条提问是另外一句话')) + Check '运行时没有标题时退回首条提问' ((Get-SessionDisplayTitle 'sess-plain' $fPlain) -eq '首条提问是另外一句话') ("得到: '" + (Get-SessionDisplayTitle 'sess-plain' $fPlain) + "'") + + # 库不存在时也不能出事 + $env:MCODE_RUNTIME_STATE_DB = Join-Path $fixture 'no-such-db.sqlite' + $script:runtimeTitlesAt = [DateTime]::MinValue + $script:runtimeTitles = @{} + $fMissing = New-Session 'sess-nodb' @((Line-User '首条提问是另外一句话')) + Check '库不存在时退回首条提问' ((Get-SessionDisplayTitle 'sess-nodb' $fMissing) -eq '首条提问是另外一句话') ("得到: '" + (Get-SessionDisplayTitle 'sess-nodb' $fMissing) + "'") + $env:MCODE_RUNTIME_STATE_DB = $null +} + Write-Output "1..$script:n" Write-Output "# pass $script:n fail $script:fail" if ($script:fail -gt 0) { exit 1 } From c2f6ff5091f1e155b86879b085b4600d9fc3cd40 Mon Sep 17 00:00:00 2001 From: antianqi Date: Fri, 23 Oct 2026 18:10:11 +0800 Subject: [PATCH 05/15] fix(mcode-island): the title lookup survives a host without the usual paths The ubuntu CI job failed on the previous commit, and it failed twice for two different reasons. Both were the same mistake: building a path out of an environment variable that does not exist everywhere. First: $env:USERPROFILE. Windows has it, the ubuntu runner does not, and Join-Path does not accept an empty path. Two places built a path out of it -- the CLI runtime lookup (~/.minimax-code/runtime) and the runtime database (~/.minimax/v2/sqlite). Both were outside the try blocks, so the exception escaped and took the tracker with it. Then: $env:TEMP. Linux PowerShell has TMPDIR, not TEMP. The temporary script for the node read went through Join-Path $env:TEMP, threw, and the catch swallowed it -- so the read returned nothing and the list silently fell back to the first question. That one is nastier: nothing crashed, the feature just quietly did not work, and the failure reported by the suite was "the title came back empty", a long way from "your temp directory variable is missing". The fix is the same shape in both places: - a home-directory lookup that accepts USERPROFILE or HOME and returns an empty string instead of throwing; - the temporary directory comes from .NET's GetTempPath(), which knows the right path on both platforms; - every function in the title path -- Get-NodeCommand, Read-RuntimeTitles, Get-RuntimeSessionTitle -- swallows its exceptions, because a missing title is not an error, and the detector's main loop calls this every tick. A missing home directory, a missing temp directory, no node, no database, a locked database, a timeout -- all of them now mean the same thing: the list shows the first question, exactly as it did before the feature existed. Validation - Two new checks in scripts/test-session-tracker.ps1: * with USERPROFILE, HOME and MCODE_RUNTIME_STATE_DB cleared, the three title functions run without throwing; * with $env:TEMP cleared but a real database pointed at, the title STILL comes back. The second one is the one that locks this bug down: the first attempt at this fix only handled the home directory and the ubuntu job still failed. - The suite on this machine: 46 pass, 0 fail for the tracker, and 133 pass, 0 fail, 1 skipped (platform) for the behavioural suite. - Reproduced locally: a child PowerShell with USERPROFILE, HOME and TEMP all cleared runs the title path and comes back with no exception and no title. Test evidence - scripts/test-session-tracker.ps1 -> 46 pass, 0 fail - node scripts/test-substep-progress.mjs -> 133 pass, 0 fail, 1 skipped Design compliance - The title is still an optional extra. Every failure mode leaves the list exactly as it was before the feature existed. - No new dependency: the same node that was already borrowed, the same read-only database, no binaries, no network, no credentials. --- .../scripts/lib/Runtime-Titles.ps1 | 124 ++++++++++++------ .../scripts/test-session-tracker.ps1 | 51 +++++++ 2 files changed, 132 insertions(+), 43 deletions(-) diff --git a/plugins/antianqi/mcode-island/scripts/lib/Runtime-Titles.ps1 b/plugins/antianqi/mcode-island/scripts/lib/Runtime-Titles.ps1 index 29b80f17..23923b2e 100644 --- a/plugins/antianqi/mcode-island/scripts/lib/Runtime-Titles.ps1 +++ b/plugins/antianqi/mcode-island/scripts/lib/Runtime-Titles.ps1 @@ -42,6 +42,15 @@ $script:RUNTIME_TITLES_TTL_MS = 60 * 1000 # 节点脚本跑多久算超时。1.9GB 的库打开要读页,1.5s 足够;超了就当没有。 $script:RUNTIME_TITLES_TIMEOUT_MS = 1500 +# 用户主目录。Windows 是 USERPROFILE,Linux/macOS 是 HOME —— CI 的 ubuntu +# job 没有 USERPROFILE,Join-Path $null 会抛异常,所以这里必须自己兜住。 +function Get-UserHome { + foreach ($v in @($env:USERPROFILE, $env:HOME)) { + if ($v) { return [string]$v } + } + return '' +} + # 找一个能用的 node。 # # 顺序有讲究: @@ -50,26 +59,35 @@ $script:RUNTIME_TITLES_TIMEOUT_MS = 1500 # 3. 桌面端 app 当 node 用 —— ELECTRON_RUN_AS_NODE=1 # # 第 3 条是给上架版用的:桌面端用户不一定装了 node,但一定跑着 app。 +# +# **这个函数不许抛异常**:没有 node 就是没有标题,不是错误。 function Get-NodeCommand { - $c = Get-Command node -ErrorAction SilentlyContinue - if ($c) { return @{ Exe = $c.Source; Env = $null } } + try { + $c = Get-Command node -ErrorAction SilentlyContinue + if ($c) { return @{ Exe = $c.Source; Env = $null } } - # CLI 自带:~/.minimax-code/runtime/node-vXX.Y.Z-win-x64/node.exe - $runtimeRoot = Join-Path $env:USERPROFILE '.minimax-code\runtime' - if (Test-Path -LiteralPath $runtimeRoot) { - $nodeExe = Get-ChildItem -LiteralPath $runtimeRoot -Recurse -Depth 2 -File -Filter 'node.exe' -ErrorAction SilentlyContinue | - Sort-Object LastWriteTime -Descending | Select-Object -First 1 - if ($nodeExe) { return @{ Exe = $nodeExe.FullName; Env = $null } } - } + # CLI 自带:~/.minimax-code/runtime/node-vXX.Y.Z-win-x64/node.exe + $home = Get-UserHome + if ($home) { + $runtimeRoot = Join-Path $home '.minimax-code\runtime' + if (Test-Path -LiteralPath $runtimeRoot) { + $nodeExe = Get-ChildItem -LiteralPath $runtimeRoot -Recurse -Depth 2 -File -Filter 'node.exe' -ErrorAction SilentlyContinue | + Sort-Object LastWriteTime -Descending | Select-Object -First 1 + if ($nodeExe) { return @{ Exe = $nodeExe.FullName; Env = $null } } + } + } - # 桌面端 app:ELECTRON_RUN_AS_NODE=1 之后 Electron 就是 node。 - # - # 从已经在跑的进程里拿 exe 路径,比去 Program Files 里猜安装位置可靠: - # 装在哪、怎么升级都不会失配。 - $names = @('MiniMax Inside Code', 'MiniMax Code', 'Mavis') - foreach ($n in $names) { - $proc = Get-Process -Name $n -ErrorAction SilentlyContinue | Where-Object { $_.Path } | Select-Object -First 1 - if ($proc) { return @{ Exe = $proc.Path; Env = 'ELECTRON_RUN_AS_NODE=1' } } + # 桌面端 app:ELECTRON_RUN_AS_NODE=1 之后 Electron 就是 node。 + # + # 从已经在跑的进程里拿 exe 路径,比去 Program Files 里猜安装位置可靠: + # 装在哪、怎么升级都不会失配。 + $names = @('MiniMax Inside Code', 'MiniMax Code', 'Mavis') + foreach ($n in $names) { + $proc = Get-Process -Name $n -ErrorAction SilentlyContinue | Where-Object { $_.Path } | Select-Object -First 1 + if ($proc) { return @{ Exe = $proc.Path; Env = 'ELECTRON_RUN_AS_NODE=1' } } + } + } catch { + # 找不到就是找不到。 } return $null } @@ -78,27 +96,36 @@ function Get-NodeCommand { # # 返回 @{ id -> title },读不到就返回空表。**任何失败都只是少一个标题**, # 不该把 detector 带崩:所以这里只吞异常,不往上抛。 +# +# 整个函数体都在 try 里,包括路径拼接 —— CI 的 ubuntu job 没有 +# USERPROFILE,Join-Path $null 会抛,那次失败就是这么来的。 function Read-RuntimeTitles { $result = @{} - # MCODE_RUNTIME_STATE_DB 可以指向别的库。测试要用一个自己建的临时库, - # 不然就只能读开发机上那个 1.9GB 的真库。 - $db = $env:MCODE_RUNTIME_STATE_DB - if (-not $db) { $db = Join-Path $env:USERPROFILE '.minimax\v2\sqlite\runtime-state.sqlite' } - if (-not (Test-Path -LiteralPath $db)) { return $result } + $tmp = $null + try { + # MCODE_RUNTIME_STATE_DB 可以指向别的库。测试要用一个自己建的临时库, + # 不然就只能读开发机上那个 1.9GB 的真库。 + $db = $env:MCODE_RUNTIME_STATE_DB + if (-not $db) { + $home = Get-UserHome + if (-not $home) { return $result } + $db = Join-Path $home '.minimax\v2\sqlite\runtime-state.sqlite' + } + if (-not (Test-Path -LiteralPath $db)) { return $result } - $node = Get-NodeCommand - if (-not $node) { return $result } + $node = Get-NodeCommand + if (-not $node) { return $result } - # 内联脚本:node:sqlite 是内置模块,只读打开,把 id -> title 打成 JSON。 - # - # 全表取而不是按 id 取:表只有几百行,一次读完比拼 IN 子句省事得多, - # 而且以后多了字段也不用改。 - # - # argv[1] 是脚本自己的路径,argv[2] 才是传进来的库路径。 - # - # 扩展名必须是 .cjs:用户目录下的 package.json 里可能有 "type": "module", - # 那会让 .js 按 ES module 加载,require() 直接报 "require is not defined"。 - $js = @' + # 内联脚本:node:sqlite 是内置模块,只读打开,把 id -> title 打成 JSON。 + # + # 全表取而不是按 id 取:表只有几百行,一次读完比拼 IN 子句省事得多, + # 而且以后多了字段也不用改。 + # + # argv[1] 是脚本自己的路径,argv[2] 才是传进来的库路径。 + # + # 扩展名必须是 .cjs:用户目录下的 package.json 里可能有 "type": "module", + # 那会让 .js 按 ES module 加载,require() 直接报 "require is not defined"。 + $js = @' const {DatabaseSync} = require("node:sqlite"); const db = new DatabaseSync(process.argv[2], {readOnly: true}); const out = {}; @@ -110,8 +137,12 @@ try { process.stdout.write(JSON.stringify(out)); '@ - $tmp = Join-Path $env:TEMP ('island-titles-' + [guid]::NewGuid().ToString('N').Substring(0, 8) + '.cjs') - try { + # 临时目录走 .NET 的 GetTempPath(),不用 $env:TEMP。 + # + # Linux 上的 PowerShell 没有 TEMP(那里叫 TMPDIR),Join-Path $null 会 + # 抛;异常被吞掉之后标题就读不出来了 —— ubuntu CI 上就是这么挂的, + # 而且失败信息离原因很远。 + $tmp = Join-Path ([System.IO.Path]::GetTempPath()) ('island-titles-' + [guid]::NewGuid().ToString('N').Substring(0, 8) + '.cjs') [System.IO.File]::WriteAllText($tmp, $js, (New-Object System.Text.UTF8Encoding($false))) $psi = New-Object System.Diagnostics.ProcessStartInfo $psi.FileName = $node.Exe @@ -135,7 +166,7 @@ process.stdout.write(JSON.stringify(out)); } catch { # 读不到就没有标题,列表退回首条提问。 } finally { - if (Test-Path -LiteralPath $tmp) { + if ($tmp -and (Test-Path -LiteralPath $tmp)) { try { [System.IO.File]::Delete($tmp) } catch {} } } @@ -145,13 +176,20 @@ process.stdout.write(JSON.stringify(out)); # 取某个 session 的标题。 # # 有缓存:标题很少变,60s 内直接用旧的。 +# +# 和读表一样,这里也不许抛:这个函数在 detector 的主循环里被调用, +# 一次异常就是整个列表停摆。 function Get-RuntimeSessionTitle([string]$id) { - if (-not $id) { return '' } - $now = Get-Date - if (($now - $script:runtimeTitlesAt).TotalMilliseconds -ge $script:RUNTIME_TITLES_TTL_MS) { - $script:runtimeTitles = Read-RuntimeTitles - $script:runtimeTitlesAt = $now + try { + if (-not $id) { return '' } + $now = Get-Date + if (($now - $script:runtimeTitlesAt).TotalMilliseconds -ge $script:RUNTIME_TITLES_TTL_MS) { + $script:runtimeTitles = Read-RuntimeTitles + $script:runtimeTitlesAt = $now + } + if ($script:runtimeTitles.ContainsKey($id)) { return [string]$script:runtimeTitles[$id] } + } catch { + # 没有标题就是没有标题。 } - if ($script:runtimeTitles.ContainsKey($id)) { return [string]$script:runtimeTitles[$id] } return '' } diff --git a/plugins/antianqi/mcode-island/scripts/test-session-tracker.ps1 b/plugins/antianqi/mcode-island/scripts/test-session-tracker.ps1 index e099acea..5bdb08d0 100644 --- a/plugins/antianqi/mcode-island/scripts/test-session-tracker.ps1 +++ b/plugins/antianqi/mcode-island/scripts/test-session-tracker.ps1 @@ -326,9 +326,60 @@ db.close(); $script:runtimeTitles = @{} $fMissing = New-Session 'sess-nodb' @((Line-User '首条提问是另外一句话')) Check '库不存在时退回首条提问' ((Get-SessionDisplayTitle 'sess-nodb' $fMissing) -eq '首条提问是另外一句话') ("得到: '" + (Get-SessionDisplayTitle 'sess-nodb' $fMissing) + "'") + + # $env:TEMP 在 Linux 的 PowerShell 上不存在(那里叫 TMPDIR)。 + # + # 临时目录必须走 .NET 的 GetTempPath()。用 $env:TEMP 的话,Join-Path + # $null 会抛,异常又被吞掉,标题就读不出来了 —— ubuntu CI 上第一次 + # 挂掉就是这个,而失败信息离原因很远。 + $oldTemp = $env:TEMP + try { + $env:TEMP = $null + $env:MCODE_RUNTIME_STATE_DB = $tmpDb + $script:runtimeTitlesAt = [DateTime]::MinValue + $script:runtimeTitles = @{} + $tNoTemp = Get-RuntimeSessionTitle 'sess-titled' + Check '没有 $env:TEMP 时也能取到标题' ($tNoTemp -eq '这个会话有一个像样的名字') ("得到: '" + $tNoTemp + "'") + } finally { + $env:TEMP = $oldTemp + } + $env:MCODE_RUNTIME_STATE_DB = $null } +# 没有主目录的环境不能抛异常。 +# +# CI 的 ubuntu job 没有 USERPROFILE,Join-Path $null 会抛。那一次 ubuntu +# 失败就是这么来的:标题是可选功能,它不该把整个 tracker 带崩。 +# +# 这条不依赖 node,所以放在 sqliteOk 的分支外面 —— 出问题最多的恰恰是 +# 那个没有 node、也没有主目录的环境。 +$oldProfile = $env:USERPROFILE +$oldHome = $env:HOME +$oldDb = $env:MCODE_RUNTIME_STATE_DB +try { + $env:USERPROFILE = $null + $env:HOME = $null + $env:MCODE_RUNTIME_STATE_DB = $null + $script:runtimeTitlesAt = [DateTime]::MinValue + $script:runtimeTitles = @{} + $survived = $true + try { + $null = Get-NodeCommand + $null = Read-RuntimeTitles + $null = Get-RuntimeSessionTitle 'sess-anything' + } catch { + $survived = $false + } + Check '没有主目录时取标题不抛异常' $survived 'Get-NodeCommand / Read-RuntimeTitles / Get-RuntimeSessionTitle 抛了异常' +} finally { + $env:USERPROFILE = $oldProfile + $env:HOME = $oldHome + $env:MCODE_RUNTIME_STATE_DB = $oldDb + $script:runtimeTitlesAt = [DateTime]::MinValue + $script:runtimeTitles = @{} +} + Write-Output "1..$script:n" Write-Output "# pass $script:n fail $script:fail" if ($script:fail -gt 0) { exit 1 } From b22599563480e4b7fe7852464b58eddd9e5e4ca7 Mon Sep 17 00:00:00 2001 From: antianqi Date: Fri, 23 Oct 2026 19:45:39 +0800 Subject: [PATCH 06/15] fix(mcode-island): a session that stopped no longer says it is running The list said "running" for a session that had been quiet for almost an hour. The cause is a freeze, not a wrong reading. The tracker decides whether a session is running from the type of its last message: a toolCall or a toolResult means the agent is busy. That is right while the agent works and wrong the moment it stops -- after the last tool runs, the last message stays a toolCall forever. On top of that, the update loop skips a session whose log has not changed: if ($entry -and $entry.MTime -eq $g.MTime) { continue } So once the agent stopped, the phase was never recomputed again. It froze on "running" and stayed there until something else happened to touch the file. The detector's own log shows it: 18:13:44 session restarted: mvs_cd09012264b5 (cycle 7) 19:31:47 session restarted: mvs_cd09012264b5 (cycle 8) Seventy-eight minutes apart, and in between the session was shown as running while it was doing nothing. The fix "Running" now needs a liveness witness as well as a message type. If a running session has been quiet for more than five minutes it is not running, it is stopped, and it says so: running the agent is busy (blue) waiting the agent is waiting for you (amber) stopped the agent is gone; the last message was a tool call that never came back (grey) Five minutes is long enough that a slow tool or a long model turn is not mistaken for a dead session, and short enough that nobody waits an hour to see the state change. The downgrade happens in the branch that used to `continue` -- the one that runs when the log has not changed. That is the point: the state has to be able to change without the log changing. "Waiting" is untouched: the ball is in the user's court, and a session the user has not answered for half an hour is still waiting. "Stopped" ends a round just like "waiting" does, so a session that comes back to life counts as a new round and the dismissal constraint is lifted -- the same rule that already applied to waiting. Validation - scripts/test-session-tracker.ps1 grew from 46 to 52 checks. The new ones stop a session (its log's timestamp is moved ten minutes into the past), let the tracker run twice -- the second run is the one that takes the "the log has not changed" path -- and assert that it is no longer reported as running, that it is reported as stopped, that a waiting session stays waiting, and that a stopped session that comes back counts as a new round. - The behavioural suite: 133 pass, 0 fail, 1 skipped (platform). - Negative injection, 3 mutations: N11 remove the liveness downgrade -> "a session that stopped for ten minutes is no longer running" goes red N12 stretch the window to ten hours -> the same checks go red N13 stop counting "stopped" as the end of a round -> "a stopped session that comes back counts as a new round" goes red - Live, on this machine: the deployed copy was temporarily given a five-second window, and the pill then showed the stopped state with a grey dot and the word "stopped" next to a session whose log was quiet. The window was restored to five minutes afterwards. Test evidence - scripts/test-session-tracker.ps1 -> 52 pass, 0 fail - node scripts/test-substep-progress.mjs -> 133 pass, 0 fail, 1 skipped - 3 negative injections -> each turned its own check red; the suite was green again after every revert Design compliance - The list keeps its three states: nothing is removed from it, so a session that stopped stays visible and says why it is idle. - Sorting follows the same order: running first, then waiting, then stopped. - No new dependency, no network, no credentials. The change is one threshold and one extra state. - UTF-8 with a BOM, LF line endings: both scripts are dot-sourced or run by Windows PowerShell 5.1 on a zh-CN host. --- .../antianqi/mcode-island/mcode-island.ps1 | 14 ++++-- .../scripts/lib/Session-Tracker.ps1 | 47 +++++++++++++++++-- .../scripts/test-session-tracker.ps1 | 40 ++++++++++++++++ 3 files changed, 93 insertions(+), 8 deletions(-) diff --git a/plugins/antianqi/mcode-island/mcode-island.ps1 b/plugins/antianqi/mcode-island/mcode-island.ps1 index 7d92f9e0..e313cc5e 100644 --- a/plugins/antianqi/mcode-island/mcode-island.ps1 +++ b/plugins/antianqi/mcode-island/mcode-island.ps1 @@ -458,8 +458,14 @@ function Format-RelativeTime([int64]$tsMs) { function Add-SessionRow([string]$id, [string]$title, [string]$phase, [string]$preview, [string]$lastTool, [int64]$lastAtMs, [bool]$isLast) { if (-not $script:sessionRows) { return } $isRunning = ($phase -eq 'running') - $dotHex = if ($isRunning) { '#FF3B82F6' } else { '#FFF59E0B' } - $phaseText = if ($isRunning) { '运行中' } else { '待回复' } + $isStopped = ($phase -eq 'stopped') + # 蓝=在跑,橙=在等你,灰=停了。 + # + # 灰色这一档是给"停了"用的:agent 跑完一个工具之后,最后一条消息 + # 永远是 toolCall,消息类型说它"在跑"。要是只看消息类型,一个停了 + # 快一小时的会话会一直显示"运行中"。 + $dotHex = if ($isRunning) { '#FF3B82F6' } elseif ($isStopped) { '#FF6B7280' } else { '#FFF59E0B' } + $phaseText = if ($isRunning) { '运行中' } elseif ($isStopped) { '已停止' } else { '待回复' } # 第二行:正文优先。没有正文(最后一条恰好是 toolCall)就退到"正在 # 调用的工具名",总比空着强。 @@ -599,7 +605,9 @@ function Show-SessionList { # # 排序用 lastAtMs(最后活动时间),不是 sinceMs(进列表的时间): # 进列表的时间只说明"我什么时候看见它",说明不了"它现在有多活跃"。 - $sorted = @($items | Sort-Object @{ Expression = { if ($_.phase -eq 'running') { 0 } else { 1 } } }, @{ Expression = { if ($_.lastAtMs) { [int64]$_.lastAtMs } else { [int64]$_.sinceMs } }; Descending = $true }) + # + # 三档顺序:在跑的排最前,在等你的排中间,停了的排最后。 + $sorted = @($items | Sort-Object @{ Expression = { if ($_.phase -eq 'running') { 0 } elseif ($_.phase -eq 'waiting') { 1 } else { 2 } } }, @{ Expression = { if ($_.lastAtMs) { [int64]$_.lastAtMs } else { [int64]$_.sinceMs } }; Descending = $true }) $i = 0 foreach ($s in $sorted) { Add-SessionRow ([string]$s.id) ([string]$s.title) ([string]$s.phase) ([string]$s.preview) ([string]$s.lastTool) ([int64]$s.lastAtMs) ($i -lt ($sorted.Count - 1)) diff --git a/plugins/antianqi/mcode-island/scripts/lib/Session-Tracker.ps1 b/plugins/antianqi/mcode-island/scripts/lib/Session-Tracker.ps1 index 509fb139..e7f15572 100644 --- a/plugins/antianqi/mcode-island/scripts/lib/Session-Tracker.ps1 +++ b/plugins/antianqi/mcode-island/scripts/lib/Session-Tracker.ps1 @@ -62,6 +62,22 @@ if (Test-Path (Join-Path $PSScriptRoot 'Runtime-Titles.ps1')) { # 60 秒足够覆盖 detector 的一次卡顿(它每秒轮询一次)。 $script:ACTIVE_WINDOW_MS = 60 * 1000 +# "运行中" 的活性窗口。 +# +# 状态不能只由消息类型定。agent 跑完一个工具之后,它的最后一条消息 +# 永远是 toolCall 或 toolResult —— 消息类型说它"在跑",其实它可能早就 +# 停了。而日志不再变之后,更新循环会直接跳过,状态会**冻结**在最后 +# 一个值:一个停了快一小时的会话会一直显示"运行中"。 +# +# 所以"在跑"必须有一份活性支撑:超过这个窗口没动,就不是"在跑",是 +# "停了"。 +# +# 5 分钟:长工具(一次几分钟的构建、大文件处理)和长时间的模型推理都 +# 不会误伤,用户也不必等一个小时才看到状态变了。 +# +# "待回复"不看这个窗口:球在用户那边,用户半小时不回,它还是待回复。 +$script:STALE_RUNNING_MS = 5 * 60 * 1000 + function Get-SessionIdFromDir($dir) { $name = [System.IO.Path]::GetFileName($dir) $i = $name.IndexOf('session_') @@ -312,7 +328,25 @@ function Update-SessionTracker { foreach ($id in @($groups.Keys)) { $g = $groups[$id] $entry = $script:trackedSessions[$id] - if ($entry -and $entry.MTime -eq $g.MTime) { continue } # 没变就不用重读 + if ($entry -and $entry.MTime -eq $g.MTime) { + # 日志没变:消息没变,状态也不该变。 + # + # 但"运行中"需要一份活性支撑。agent 跑完一个工具之后,它的最后 + # 一条消息永远是 toolCall 或 toolResult —— 消息类型说它"在跑", + # 其实它可能早就停了。而这一行原本直接 continue,状态会冻结在 + # 最后一个值:一个停了快一小时的会话会一直显示"运行中"。 + # + # 所以在这里降级一次:超过窗口没动就不是"在跑",是"停了"。 + if ($entry.Phase -eq 'running') { + $ageMs = ($now - $g.MTime).TotalMilliseconds + if ($ageMs -gt $script:STALE_RUNNING_MS) { + $entry.Phase = 'stopped' + $script:sessionsDirty = $true + Log-Line ("session stopped: " + $id + " (quiet for " + [int]($ageMs / 1000) + "s)") + } + } + continue + } $msg = Get-LastJsonLine $g.Log if (-not $msg) { continue } @@ -330,9 +364,9 @@ function Update-SessionTracker { $cycle = 1 if ($script:dismissedSessions.ContainsKey($id)) { $d = $script:dismissedSessions[$id] - # 被点掉过。上次看到它是"待回复",现在又"运行中" -> 它跑完了一轮 - # 又开始,是新的一轮,可以重新进来。 - if ($d.Phase -eq 'waiting' -and $phase -eq 'running') { + # 被点掉过。上次看到它是"待回复"或"已停止",现在又"运行中" -> + # 它跑完了一轮又开始,是新的一轮,可以重新进来。 + if (($d.Phase -eq 'waiting' -or $d.Phase -eq 'stopped') -and $phase -eq 'running') { $cycle = $d.Cycle + 1 $script:dismissedSessions.Remove($id) $script:sessionsDirty = $true @@ -366,7 +400,10 @@ function Update-SessionTracker { } # 已跟踪的:状态变了就更新 - if ($entry.Phase -eq 'waiting' -and $phase -eq 'running') { + # + # "已停止"和"待回复"一样,是一轮的终点:agent 停了,或者它说完了。 + # 之后又变成"运行中",说明新一轮开始了,之前被点掉的约束失效。 + if (($entry.Phase -eq 'waiting' -or $entry.Phase -eq 'stopped') -and $phase -eq 'running') { # 一轮结束了,agent 又开始 -> 新的一轮,之前被点掉的约束失效 $entry.Cycle = $entry.Cycle + 1 if ($script:dismissedSessions.ContainsKey($id)) { diff --git a/plugins/antianqi/mcode-island/scripts/test-session-tracker.ps1 b/plugins/antianqi/mcode-island/scripts/test-session-tracker.ps1 index 5bdb08d0..75629c25 100644 --- a/plugins/antianqi/mcode-island/scripts/test-session-tracker.ps1 +++ b/plugins/antianqi/mcode-island/scripts/test-session-tracker.ps1 @@ -380,6 +380,46 @@ try { $script:runtimeTitles = @{} } +# --- 10. 停了的会话不该一直显示"运行中" ----------------------------------- +# +# agent 跑完一个工具之后,它的最后一条消息永远是 toolCall 或 toolResult +# —— 消息类型说它"在跑"。而日志不再变之后,更新循环第一行就跳过了, +# 状态冻结在最后一个值:一个停了快一小时的会话会一直显示"运行中"。 +# +# 所以"在跑"必须有一份活性支撑:超过窗口没动,就不是"在跑",是"停了"。 +$fStale = New-Session 'sess-stale' @((Line-ToolCall 'Bash')) +Update-SessionTracker +Check '刚动过的 toolCall 会话是运行中' ($script:trackedSessions['sess-stale'].Phase -eq 'running') ("得到: " + $script:trackedSessions['sess-stale'].Phase) + +# 停 10 分钟:文件 mtime 变了,先重读一次(消息没变,状态也没变) +[System.IO.File]::SetLastWriteTimeUtc($fStale, [DateTime]::UtcNow.AddMinutes(-10)) +Update-SessionTracker +# 第二轮:mtime 不会再变了,这一轮走的是"没变就跳过"那条路 +Update-SessionTracker +Check '停了 10 分钟的会话不再显示运行中' ($script:trackedSessions['sess-stale'].Phase -ne 'running') ("得到: " + $script:trackedSessions['sess-stale'].Phase) +Check '停了以后标成已停止' ($script:trackedSessions['sess-stale'].Phase -eq 'stopped') ("得到: " + $script:trackedSessions['sess-stale'].Phase) + +# "待回复"不看活性:球在用户那边,用户半小时不回也是待回复。 +$fWaitStale = New-Session 'sess-waitstale' @((Line-Assistant '说完了,等你')) +Update-SessionTracker +[System.IO.File]::SetLastWriteTimeUtc($fWaitStale, [DateTime]::UtcNow.AddMinutes(-10)) +Update-SessionTracker +Update-SessionTracker +Check '停了 10 分钟的待回复会话仍然是待回复' ($script:trackedSessions['sess-waitstale'].Phase -eq 'waiting') ("得到: " + $script:trackedSessions['sess-waitstale'].Phase) + +# "已停止"和"待回复"一样,是一轮的终点。之后又变成"运行中",说明 +# 新一轮开始了 —— 被点掉的约束失效,cycle +1。 +$fStopCycle = New-Session 'sess-stopcycle' @((Line-ToolCall 'Bash')) +Update-SessionTracker +[System.IO.File]::SetLastWriteTimeUtc($fStopCycle, [DateTime]::UtcNow.AddMinutes(-10)) +Update-SessionTracker +Update-SessionTracker +Check '停了以后是已停止' ($script:trackedSessions['sess-stopcycle'].Phase -eq 'stopped') ("得到: " + $script:trackedSessions['sess-stopcycle'].Phase) +$cycleBefore = $script:trackedSessions['sess-stopcycle'].Cycle +[System.IO.File]::WriteAllText($fStopCycle, ((Line-ToolCall 'Bash') + "`n"), (New-Object System.Text.UTF8Encoding($false))) +Update-SessionTracker +Check '停了以后又跑起来算新一轮' ($script:trackedSessions['sess-stopcycle'].Cycle -eq ($cycleBefore + 1)) ("得到: " + $script:trackedSessions['sess-stopcycle'].Cycle + ", 之前 " + $cycleBefore) + Write-Output "1..$script:n" Write-Output "# pass $script:n fail $script:fail" if ($script:fail -gt 0) { exit 1 } From ee06a149222351ce7185864216583e4e69d75df4 Mon Sep 17 00:00:00 2001 From: antianqi Date: Fri, 23 Oct 2026 20:21:52 +0800 Subject: [PATCH 07/15] feat(mcode-island): the list shows the same states the pill shows The pill could say "waiting for approval" and "done"; the list could say "running", "waiting" and "stopped". Two different vocabularies for the same thing, and they disagreed: when the user answered a question, the pill said "waiting for the user" (the last message was role=user, which the detector reads as idle) while the list said "running" (the tracker read role=user as "the agent is about to move"). The user could see one session showing two different things at once, in two places on the same screen. The fix The list now produces the pill's state vocabulary and nothing else: working the agent is busy -> running thinking the agent is thinking -> thinking waiting the agent is waiting for approval -> waiting for approval idle the agent is done, or the user just spoke -> waiting for you done a tool finished -> done error a tool failed -> error Same words, same colours, same meaning in both places. The tracker reads the message the same way the detector does -- the same scan for toolCall / thinking / text, the same ask_user check -- and the list just renders the result. The "stopped" state stays: it is not part of the pill's vocabulary, and it is not a message type. It is what a session looks like when its last message says "running" but nothing has happened for five minutes. The round-end bookkeeping follows the new vocabulary: idle, waiting, done, error and stopped all end a round, and working or thinking starts the next one. That is what releases a dismissed session. Validation - scripts/test-session-tracker.ps1 grew from 52 to 57 checks. The new ones pin the whole vocabulary: toolCall -> working, assistant text -> idle, ask_user -> waiting, user just spoke -> idle, tool result -> done, failed tool -> error, thinking only -> thinking. Plus the liveness fallback for a thinking session, which was not covered before: a session stuck in thinking used to stay "thinking" for ever, the same class of bug as the one that was "running" for ever. - The suite on this machine: 57 pass, 0 fail for the tracker, and 133 pass, 0 fail, 1 skipped (platform) for the behavioural suite. - Negative injection, 5 mutations: N14 a tool result is no longer "done" -> "a tool result means done" goes red N15 the user having just spoken is "running" again (the old list semantics) -> "the user just spoke means waiting for you" goes red N16 assistant text becomes "waiting" again -> "assistant text means waiting for you" goes red N17 the thinking state is gone -> "an assistant that only thinks is thinking" goes red N18 the liveness fallback no longer knows about thinking -> "a thinking session that stopped is downgraded" goes red - Live: the list showed "running" in blue and "done" in green, with the agent's own last sentence as the second line. Test evidence - scripts/test-session-tracker.ps1 -> 57 pass, 0 fail - node scripts/test-substep-progress.mjs -> 133 pass, 0 fail, 1 skipped Design compliance - The pill's own behaviour is untouched. This change only makes the list agree with it. - The states file keeps its "phase" field name, so a sessions.json written by an older detector still loads; the old "running" value is still understood. - No new dependency, no network, no credentials. - UTF-8 with a BOM, LF line endings: both scripts are dot-sourced or run by Windows PowerShell 5.1 on a zh-CN host. --- .../antianqi/mcode-island/mcode-island.ps1 | 39 +++++-- .../scripts/lib/Session-Tracker.ps1 | 105 +++++++++++++----- .../scripts/test-session-tracker.ps1 | 61 ++++++++-- 3 files changed, 159 insertions(+), 46 deletions(-) diff --git a/plugins/antianqi/mcode-island/mcode-island.ps1 b/plugins/antianqi/mcode-island/mcode-island.ps1 index e313cc5e..774a4199 100644 --- a/plugins/antianqi/mcode-island/mcode-island.ps1 +++ b/plugins/antianqi/mcode-island/mcode-island.ps1 @@ -457,15 +457,30 @@ function Format-RelativeTime([int64]$tsMs) { # 状态点跨两行、垂直居中;标题和摘要都在中间列,所以天然左对齐。 function Add-SessionRow([string]$id, [string]$title, [string]$phase, [string]$preview, [string]$lastTool, [int64]$lastAtMs, [bool]$isLast) { if (-not $script:sessionRows) { return } - $isRunning = ($phase -eq 'running') - $isStopped = ($phase -eq 'stopped') - # 蓝=在跑,橙=在等你,灰=停了。 + # 状态 -> 文案 + 颜色。 # - # 灰色这一档是给"停了"用的:agent 跑完一个工具之后,最后一条消息 - # 永远是 toolCall,消息类型说它"在跑"。要是只看消息类型,一个停了 - # 快一小时的会话会一直显示"运行中"。 - $dotHex = if ($isRunning) { '#FF3B82F6' } elseif ($isStopped) { '#FF6B7280' } else { '#FFF59E0B' } - $phaseText = if ($isRunning) { '运行中' } elseif ($isStopped) { '已停止' } else { '待回复' } + # 用的是 pill 那套状态词表(working/thinking/waiting/idle/done/error), + # 颜色也跟着 pill 走:同一个状态在两个地方就是同一个颜色、同一句话。 + # 之前这里自己写了一套 running/waiting/stopped,于是同一个会话在 pill + # 上显示"等用户"、在列表里显示"运行中",pill 有的"已完成"列表里更是 + # 根本没有。 + # + # "running" 是旧词表的值,留着当兼容:过渡期里 sessions.json 可能还是 + # 旧的。 + $stateTextMap = @{ + 'working' = @{ text = '运行中'; dot = '#FF3B82F6' } + 'thinking' = @{ text = '思考中'; dot = '#FFEAB308' } + 'waiting' = @{ text = '等待审批'; dot = '#FFF59E0B' } + 'idle' = @{ text = '待回复'; dot = '#FF6B7280' } + 'done' = @{ text = '已完成'; dot = '#FF22C55E' } + 'error' = @{ text = '出错'; dot = '#FFEF4444' } + 'stopped' = @{ text = '已停止'; dot = '#FF4B5563' } + 'running' = @{ text = '运行中'; dot = '#FF3B82F6' } + } + $stateInfo = $stateTextMap[[string]$phase] + if (-not $stateInfo) { $stateInfo = $stateTextMap['idle'] } + $dotHex = $stateInfo.dot + $phaseText = $stateInfo.text # 第二行:正文优先。没有正文(最后一条恰好是 toolCall)就退到"正在 # 调用的工具名",总比空着强。 @@ -606,8 +621,12 @@ function Show-SessionList { # 排序用 lastAtMs(最后活动时间),不是 sinceMs(进列表的时间): # 进列表的时间只说明"我什么时候看见它",说明不了"它现在有多活跃"。 # - # 三档顺序:在跑的排最前,在等你的排中间,停了的排最后。 - $sorted = @($items | Sort-Object @{ Expression = { if ($_.phase -eq 'running') { 0 } elseif ($_.phase -eq 'waiting') { 1 } else { 2 } } }, @{ Expression = { if ($_.lastAtMs) { [int64]$_.lastAtMs } else { [int64]$_.sinceMs } }; Descending = $true }) + # 顺序:在跑的排最前,然后是等你的、再是等完了的,停了的排最后。 + $stateOrder = @{ + 'working' = 0; 'thinking' = 0; 'waiting' = 1; 'idle' = 2 + 'done' = 3; 'error' = 4; 'stopped' = 5; 'running' = 0 + } + $sorted = @($items | Sort-Object @{ Expression = { $k = [string]$_.phase; if ($stateOrder.ContainsKey($k)) { $stateOrder[$k] } else { 2 } } }, @{ Expression = { if ($_.lastAtMs) { [int64]$_.lastAtMs } else { [int64]$_.sinceMs } }; Descending = $true }) $i = 0 foreach ($s in $sorted) { Add-SessionRow ([string]$s.id) ([string]$s.title) ([string]$s.phase) ([string]$s.preview) ([string]$s.lastTool) ([int64]$s.lastAtMs) ($i -lt ($sorted.Count - 1)) diff --git a/plugins/antianqi/mcode-island/scripts/lib/Session-Tracker.ps1 b/plugins/antianqi/mcode-island/scripts/lib/Session-Tracker.ps1 index e7f15572..710f6407 100644 --- a/plugins/antianqi/mcode-island/scripts/lib/Session-Tracker.ps1 +++ b/plugins/antianqi/mcode-island/scripts/lib/Session-Tracker.ps1 @@ -180,44 +180,93 @@ function Get-SessionTitle($file) { return $title } -# 这个 session 现在是"运行中"还是"待回复"。 +# 这个 session 现在是什么状态。 # -# running agent 还在干:toolCall / toolResult / thinking / 用户刚说完 -# waiting agent 停了,在等用户:它说完了,或者它在提问(ask_user) +# **和 detector 的 Infer-State 同源**:同一套状态词表、同一套判定规则。 # -# ask_user 归到 waiting:球在用户这边,agent 在等答案。 -function Get-SessionPhase($msg) { - if (-not $msg) { return 'running' } +# 之前这里自己写了一套 running/waiting/stopped,两边越走越远:"用户刚 +# 说完"在 pill 上是 idle(等用户),在列表里却是 running;pill 有的 +# "已完成"(工具跑完)列表里根本没有。同一个会话在两个地方显示成两回事。 +# +# 所以这里直接产出 pill 那套状态: +# +# working agent 在干活(跑工具) +# thinking agent 在想 +# waiting agent 在等审批(ask_user)—— 球在用户那边 +# idle agent 说完了,或者用户刚说完 —— 球在用户那边 +# done 工具跑完了 +# error 工具炸了 +# +# 只产出状态,不产出 message 和 family —— 那两个是 pill 的活("Ran"、 +# "Read" 这些动词),列表不需要。 +function Get-SessionState($msg) { + if (-not $msg) { return '' } $m = $msg.message if (-not $m) { # ledger 事件格式:{kind, phase, action, ...} $kind = [string]$msg.kind - if ($kind -eq $S_TOOLRESULT) { return 'running' } + if ($kind -eq $S_TOOLRESULT) { + if ($msg.isError -eq $true) { return 'error' } + return 'done' + } if ($kind -eq 'toolCall' -or $kind -eq $S_TOOLCALL) { if (Test-IsAskUser ([string]$msg.toolName)) { return 'waiting' } - return 'running' + return 'working' } - return 'running' + if ($kind -eq $S_ASSISTANT -and [string]$msg.phase -eq $S_THINKING) { return 'thinking' } + return '' } $role = [string]$m.role - if ($role -eq $S_USER) { return 'running' } # 用户刚说完,agent 要动 - if ($role -eq $S_TOOLRESULT) { return 'running' } + # 用户刚说完:球在用户那边,agent 马上要动。pill 也判 idle("等用户")。 + if ($role -eq $S_USER) { return 'idle' } if ($role -eq $S_ASSISTANT) { - foreach ($c in @($m.content)) { - if ($c.type -eq $S_TOOLCALL) { - if (Test-IsAskUser ([string]$c.name)) { return 'waiting' } - return 'running' + # 和 detector 一样扫一遍 content:toolCall 优先,命中就停。 + $hasTool = $null + $hasThink = $null + $hasText = $null + if ($m.content) { + foreach ($c in @($m.content)) { + if ($hasTool -eq $null -and $c.type -eq $S_TOOLCALL) { $hasTool = $c } + elseif ($hasThink -eq $null -and $c.type -eq $S_THINK_BLK) { $hasThink = $c } + elseif ($hasText -eq $null -and $c.type -eq $S_TEXT) { $hasText = $c } + if ($hasTool) { break } } } - return 'waiting' # assistant 纯文本 = 说完了 + if ($hasTool) { + if (Test-IsAskUser ([string]$hasTool.name)) { return 'waiting' } + return 'working' + } + if ($hasThink -and -not $hasText) { return 'thinking' } + if ($hasText) { return 'idle' } + return 'idle' } - return 'running' + if ($role -eq $S_TOOLRESULT) { + if ($m.isError -eq $true) { return 'error' } + return 'done' + } + return '' } +# 哪些状态算"一轮的终点":球不在 agent 那边了。 +# +# idle agent 说完了,或者用户刚说完 +# waiting agent 在等审批(ask_user) +# done 工具跑完了 +# stopped agent 挂了 +# error 工具炸了 +# +# 这些状态之后又变成 working/thinking,说明新一轮开始了,被点掉的约束 +# 该失效。 +$script:ROUND_END_STATES = @('idle', 'waiting', 'done', 'stopped', 'error') + +# 哪些状态算"在跑"(agent 那边有活)。活性兜底只对它们生效:最后一条 +# 消息说它在跑,但太久没动静了,那它不是"在跑",是"停了"。 +$script:ACTIVE_STATES = @('working', 'thinking') + # 最后一条消息的正文摘要(列表的第二行)。 # # 从同一条消息里取,零额外 IO:Get-LastJsonLine 已经把它读进内存了, -# Get-SessionPhase 也已经解析过 $m.content,只是没取正文。 +# Get-SessionState 也已经解析过 $m.content,只是没取正文。 # # 这里只负责"把原文取出来",不负责怎么排版:摘要的格式(截断长度、 # "执行 " 这种前缀)归 widget 管,detector 只给原始数据。这样 @@ -337,7 +386,7 @@ function Update-SessionTracker { # 最后一个值:一个停了快一小时的会话会一直显示"运行中"。 # # 所以在这里降级一次:超过窗口没动就不是"在跑",是"停了"。 - if ($entry.Phase -eq 'running') { + if ($entry.Phase -in $script:ACTIVE_STATES) { $ageMs = ($now - $g.MTime).TotalMilliseconds if ($ageMs -gt $script:STALE_RUNNING_MS) { $entry.Phase = 'stopped' @@ -350,7 +399,7 @@ function Update-SessionTracker { $msg = Get-LastJsonLine $g.Log if (-not $msg) { continue } - $phase = Get-SessionPhase $msg + $phase = Get-SessionState $msg if (-not $entry) { # 新 session:只有"刚动过"才进列表。 @@ -364,9 +413,9 @@ function Update-SessionTracker { $cycle = 1 if ($script:dismissedSessions.ContainsKey($id)) { $d = $script:dismissedSessions[$id] - # 被点掉过。上次看到它是"待回复"或"已停止",现在又"运行中" -> + # 被点掉过。上次看到它是一轮的终点,现在又在跑 -> # 它跑完了一轮又开始,是新的一轮,可以重新进来。 - if (($d.Phase -eq 'waiting' -or $d.Phase -eq 'stopped') -and $phase -eq 'running') { + if (($d.Phase -in $script:ROUND_END_STATES) -and ($phase -in $script:ACTIVE_STATES)) { $cycle = $d.Cycle + 1 $script:dismissedSessions.Remove($id) $script:sessionsDirty = $true @@ -401,9 +450,9 @@ function Update-SessionTracker { # 已跟踪的:状态变了就更新 # - # "已停止"和"待回复"一样,是一轮的终点:agent 停了,或者它说完了。 - # 之后又变成"运行中",说明新一轮开始了,之前被点掉的约束失效。 - if (($entry.Phase -eq 'waiting' -or $entry.Phase -eq 'stopped') -and $phase -eq 'running') { + # 一轮的终点(idle/waiting/done/stopped/error)之后又变成在跑 + # (working/thinking),说明新一轮开始了,之前被点掉的约束失效。 + if (($entry.Phase -in $script:ROUND_END_STATES) -and ($phase -in $script:ACTIVE_STATES)) { # 一轮结束了,agent 又开始 -> 新的一轮,之前被点掉的约束失效 $entry.Cycle = $entry.Cycle + 1 if ($script:dismissedSessions.ContainsKey($id)) { @@ -517,7 +566,7 @@ function Restore-SessionTracker { # 老格式是一个整数(cycle)。当成"消掉时正在跑",于是它要等 # 跑完一轮又开始才回来。 if ($v -is [int] -or $v -is [long] -or $v -is [double]) { - $v = @{ Cycle = [int]$v; Phase = 'running' } + $v = @{ Cycle = [int]$v; Phase = 'working' } } $script:dismissedSessions[$p.Name] = $v } @@ -553,7 +602,7 @@ function Seed-SessionTracker { $msg = Get-LastJsonLine $g.Log if (-not $msg) { continue } - $phase = Get-SessionPhase $msg + $phase = Get-SessionState $msg # 两种补录: # - 刚动过(60 秒内):它此刻真的在跑 @@ -561,7 +610,7 @@ function Seed-SessionTracker { # 重启前它刚说完,那次"从运行中变成待回复"的转换我们没看见, # 不补录的话这个"待回复"就永远等不到了 $isActive = ($g.MTime -ge $activeCut) - $isFinishedWaiting = ($g.MTime -ge $seedCut) -and ($phase -eq 'waiting') + $isFinishedWaiting = ($g.MTime -ge $seedCut) -and ($phase -in $script:ROUND_END_STATES) if (-not $isActive -and -not $isFinishedWaiting) { continue } $script:trackedSessions[$id] = @{ diff --git a/plugins/antianqi/mcode-island/scripts/test-session-tracker.ps1 b/plugins/antianqi/mcode-island/scripts/test-session-tracker.ps1 index 75629c25..20586b45 100644 --- a/plugins/antianqi/mcode-island/scripts/test-session-tracker.ps1 +++ b/plugins/antianqi/mcode-island/scripts/test-session-tracker.ps1 @@ -35,6 +35,10 @@ New-Item -ItemType Directory -Path $env:APPDATA -Force | Out-Null $S_USER = 'user'; $S_ASSISTANT = 'assistant'; $S_TOOLRESULT = 'toolResult' $S_TOOLCALL = 'toolCall'; $FNAME_LEDGER = 'ledger.jsonl'; $FNAME_MESSAGES = 'messages.jsonl' $S_THINKING = 'thinking' +# The tracker reads content blocks by type now, exactly like the +# detector's Infer-State does. Without these two the text/thinking blocks +# are never recognised -- and the state comes out wrong. +$S_TEXT = 'text'; $S_THINK_BLK = 'thinking' function Log-Line($msg) { } function Test-IsAskUser($name) { return ([string]$name) -eq 'AskUserQuestion' } @@ -90,6 +94,16 @@ function Line-Assistant([string]$text) { function Line-ToolCall([string]$tool) { return '{"message":{"role":"assistant","content":[{"type":"toolCall","name":"' + $tool + '","arguments":{}}],"timestamp":3}}' } +# A tool that failed. The detector reports these as `error`, so the list +# has to be able to say it too. +function Line-ToolResultFailed { + return '{"message":{"role":"toolResult","isError":true,"content":[{"type":"text","text":"boom"}],"timestamp":4}}' +} +# An assistant message with only a thinking block: it is thinking, not +# done and not working. +function Line-ThinkingOnly { + return '{"message":{"role":"assistant","content":[{"type":"thinking","thinking":"hmm"}],"timestamp":4}}' +} function Line-ToolResult { return '{"message":{"role":"toolResult","content":[{"type":"text","text":"ok"}],"timestamp":4}}' } @@ -106,10 +120,28 @@ $fDone = New-Session 'sess-waiting' @((Line-Assistant '我先去做点别的')) $fAsk = New-Session 'sess-asking' @((Line-ToolCall 'ask_user')) $fUser = New-Session 'sess-userjust' @((Line-User '帮我看下这个')) -Check 'toolCall 结尾 = 运行中' ((Get-SessionPhase (Get-LastJsonLine $fTool)) -eq 'running') (Get-SessionPhase (Get-LastJsonLine $fTool)) -Check 'assistant 文本结尾 = 待回复' ((Get-SessionPhase (Get-LastJsonLine $fDone)) -eq 'waiting') (Get-SessionPhase (Get-LastJsonLine $fDone)) -Check 'ask_user 结尾 = 待回复' ((Get-SessionPhase (Get-LastJsonLine $fAsk)) -eq 'waiting') (Get-SessionPhase (Get-LastJsonLine $fAsk)) -Check 'user 刚说完 = 运行中' ((Get-SessionPhase (Get-LastJsonLine $fUser)) -eq 'running') (Get-SessionPhase (Get-LastJsonLine $fUser)) +# 这些状态词表和 pill(detector 的 Infer-State)是同一套:同一个会话在 +# 两个地方不能一个说"等用户"、一个说"运行中"。 +# +# toolCall 结尾 -> working (在跑) +# assistant 纯文本结尾 -> idle (说完了,等你) +# ask_user 结尾 -> waiting (在等审批) +# user 刚说完 -> idle (球在你那边,agent 马上要动) +# toolResult 成功 -> done (工具跑完了) +# toolResult 失败 -> error +# assistant 只有 thinking -> thinking(在想) +Check 'toolCall 结尾 = 运行中' ((Get-SessionState (Get-LastJsonLine $fTool)) -eq 'working') (Get-SessionState (Get-LastJsonLine $fTool)) +Check 'assistant 文本结尾 = 待回复' ((Get-SessionState (Get-LastJsonLine $fDone)) -eq 'idle') (Get-SessionState (Get-LastJsonLine $fDone)) +Check 'ask_user 结尾 = 等待审批' ((Get-SessionState (Get-LastJsonLine $fAsk)) -eq 'waiting') (Get-SessionState (Get-LastJsonLine $fAsk)) +Check 'user 刚说完 = 待回复' ((Get-SessionState (Get-LastJsonLine $fUser)) -eq 'idle') (Get-SessionState (Get-LastJsonLine $fUser)) + +# 工具跑完 / 炸了 / 只想不动手:pill 有这几档,列表以前一个都没有。 +$fRes = New-Session 'sess-toolresult' @((Line-ToolResult)) +$fResErr = New-Session 'sess-toolerror' @((Line-ToolResultFailed)) +$fThink = New-Session 'sess-thinking' @((Line-ThinkingOnly)) +Check 'toolResult 结尾 = 已完成' ((Get-SessionState (Get-LastJsonLine $fRes)) -eq 'done') (Get-SessionState (Get-LastJsonLine $fRes)) +Check '工具失败 = 出错' ((Get-SessionState (Get-LastJsonLine $fResErr)) -eq 'error') (Get-SessionState (Get-LastJsonLine $fResErr)) +Check 'assistant 只想不动手 = 思考中' ((Get-SessionState (Get-LastJsonLine $fThink)) -eq 'thinking') (Get-SessionState (Get-LastJsonLine $fThink)) # --- 2. 标题提取 --------------------------------------------------------- $reminder = "`nxxx`n`n帮我把 PR 看一下" @@ -140,7 +172,7 @@ $before = $script:trackedSessions['sess-flip'].Phase [System.IO.File]::WriteAllText($fNew2, ((Line-Assistant '做完了') + "`n"), (New-Object System.Text.UTF8Encoding($false))) Update-SessionTracker $after = $script:trackedSessions['sess-flip'].Phase -Check '运行中 -> 待回复 的状态转换' (($before -eq 'running') -and ($after -eq 'waiting')) ("$before -> $after") +Check '运行中 -> 待回复 的状态转换' (($before -eq 'working') -and ($after -eq 'idle')) ("$before -> $after") # --- 5. 消除:ack.signal ---------------------------------------------------- [System.IO.File]::WriteAllText((Join-Path $configDir 'ack.signal'), 'sess-flip', (New-Object System.Text.UTF8Encoding($false))) @@ -389,14 +421,14 @@ try { # 所以"在跑"必须有一份活性支撑:超过窗口没动,就不是"在跑",是"停了"。 $fStale = New-Session 'sess-stale' @((Line-ToolCall 'Bash')) Update-SessionTracker -Check '刚动过的 toolCall 会话是运行中' ($script:trackedSessions['sess-stale'].Phase -eq 'running') ("得到: " + $script:trackedSessions['sess-stale'].Phase) +Check '刚动过的 toolCall 会话是运行中' ($script:trackedSessions['sess-stale'].Phase -eq 'working') ("得到: " + $script:trackedSessions['sess-stale'].Phase) # 停 10 分钟:文件 mtime 变了,先重读一次(消息没变,状态也没变) [System.IO.File]::SetLastWriteTimeUtc($fStale, [DateTime]::UtcNow.AddMinutes(-10)) Update-SessionTracker # 第二轮:mtime 不会再变了,这一轮走的是"没变就跳过"那条路 Update-SessionTracker -Check '停了 10 分钟的会话不再显示运行中' ($script:trackedSessions['sess-stale'].Phase -ne 'running') ("得到: " + $script:trackedSessions['sess-stale'].Phase) +Check '停了 10 分钟的会话不再显示运行中' ($script:trackedSessions['sess-stale'].Phase -ne 'working') ("得到: " + $script:trackedSessions['sess-stale'].Phase) Check '停了以后标成已停止' ($script:trackedSessions['sess-stale'].Phase -eq 'stopped') ("得到: " + $script:trackedSessions['sess-stale'].Phase) # "待回复"不看活性:球在用户那边,用户半小时不回也是待回复。 @@ -405,7 +437,20 @@ Update-SessionTracker [System.IO.File]::SetLastWriteTimeUtc($fWaitStale, [DateTime]::UtcNow.AddMinutes(-10)) Update-SessionTracker Update-SessionTracker -Check '停了 10 分钟的待回复会话仍然是待回复' ($script:trackedSessions['sess-waitstale'].Phase -eq 'waiting') ("得到: " + $script:trackedSessions['sess-waitstale'].Phase) +Check '停了 10 分钟的待回复会话仍然是待回复' ($script:trackedSessions['sess-waitstale'].Phase -eq 'idle') ("得到: " + $script:trackedSessions['sess-waitstale'].Phase) + +# thinking 也是"在跑":agent 正在想。停住了一样要降级成"已停止"。 +# +# 这条盯的是 ACTIVE_STATES 是不是完整 —— 只写 working 的话,一个卡在 +# 思考里的会话会永远显示"思考中",和当初那个"停了还显示运行中"是同 +# 一类毛病。 +$fThinkStale = New-Session 'sess-thinkstale' @((Line-ThinkingOnly)) +Update-SessionTracker +Check '刚动过的 thinking 会话是思考中' ($script:trackedSessions['sess-thinkstale'].Phase -eq 'thinking') ("得到: " + $script:trackedSessions['sess-thinkstale'].Phase) +[System.IO.File]::SetLastWriteTimeUtc($fThinkStale, [DateTime]::UtcNow.AddMinutes(-10)) +Update-SessionTracker +Update-SessionTracker +Check '停了 10 分钟的思考中会话降级成已停止' ($script:trackedSessions['sess-thinkstale'].Phase -eq 'stopped') ("得到: " + $script:trackedSessions['sess-thinkstale'].Phase) # "已停止"和"待回复"一样,是一轮的终点。之后又变成"运行中",说明 # 新一轮开始了 —— 被点掉的约束失效,cycle +1。 From 83c3856ce62429f71423e89aa689ae61592e505c Mon Sep 17 00:00:00 2001 From: antianqi Date: Fri, 23 Oct 2026 20:38:27 +0800 Subject: [PATCH 08/15] fix(mcode-island): a finished tool is not a finished round The list said "done" for any session whose last message was a tool result. Read in the list -- where one row is one session -- that says "this session is finished". It is not: the tool finished, the round did not. The agent still has to write its next message, or call the next tool, or hand control back. So the pill and the list were answering two different questions and showing it in the same words. The pill's "done" is a flash of feedback about the last action ("done" plus "Read", "Edited"); the list's "done" sat there for as long as the session did, saying something that was not true. A tool result -- successful or failed -- now means "working". The round is still going. The list says "running", the agent's own last sentence or the tool it is calling fills the second line, and the only thing that ends a round is the agent finishing its turn, which is "waiting for you". That also makes the two places agree without making them identical. The pill still flashes "done" when a tool finishes -- it is answering "what just happened". The list answers "what state is this session in", and a session that just ran a tool is running. The "stopped" state is unaffected: it is not a message type either, it is what a session looks like when its last message says "running" but nothing has happened for five minutes. Validation - scripts/test-session-tracker.ps1 grew from 57 to 59 checks. The new ones pin the tool result in both message formats: a tool result in messages.jsonl and the same event in ledger.jsonl both mean "working", not "done" and not "idle". - The suite on this machine: 59 pass, 0 fail for the tracker, and 133 pass, 0 fail, 1 skipped (platform) for the behavioural suite. - Negative injection, 6 mutations: N14 a tool result counts as the end of the round -> "a tool result means running" goes red N19 the same rule broken in the ledger branch -> "a ledger tool result means running" goes red N15 the user having just spoken is "running" again -> "the user just spoke means waiting for you" goes red N16 assistant text becomes "waiting" again -> "assistant text means waiting for you" goes red N17 the thinking state is gone -> "an assistant that only thinks is thinking" goes red N18 the liveness fallback no longer knows about thinking -> "a thinking session that stopped is downgraded" goes red - Live: the list shows running, running and waiting for you. The pill above it still says "done" for the instant after a tool finishes. Test evidence - scripts/test-session-tracker.ps1 -> 59 pass, 0 fail - node scripts/test-substep-progress.mjs -> 133 pass, 0 fail, 1 skipped Design compliance - The pill's own behaviour is untouched. It still flashes "done" after a tool, which is what a user wants to see at that moment. - The list keeps the session-level vocabulary only: running, thinking, waiting for approval, waiting for you, stopped. - No new dependency, no network, no credentials. - UTF-8 with a BOM, LF line endings: both scripts are dot-sourced or run by Windows PowerShell 5.1 on a zh-CN host. --- .../scripts/lib/Session-Tracker.ps1 | 26 ++++++++++-------- .../scripts/test-session-tracker.ps1 | 27 ++++++++++++++++--- 2 files changed, 39 insertions(+), 14 deletions(-) diff --git a/plugins/antianqi/mcode-island/scripts/lib/Session-Tracker.ps1 b/plugins/antianqi/mcode-island/scripts/lib/Session-Tracker.ps1 index 710f6407..66515324 100644 --- a/plugins/antianqi/mcode-island/scripts/lib/Session-Tracker.ps1 +++ b/plugins/antianqi/mcode-island/scripts/lib/Session-Tracker.ps1 @@ -190,12 +190,15 @@ function Get-SessionTitle($file) { # # 所以这里直接产出 pill 那套状态: # -# working agent 在干活(跑工具) +# working agent 在干活(跑工具,或者刚跑完一个工具、还要接着干) # thinking agent 在想 # waiting agent 在等审批(ask_user)—— 球在用户那边 # idle agent 说完了,或者用户刚说完 —— 球在用户那边 -# done 工具跑完了 -# error 工具炸了 +# +# 注意这里**没有** done 和 error:pill 上的"已完成""出错"是"刚刚跑完 +# 一个工具""这个工具炸了"的瞬时反馈,后面还跟着具体动词;放进列表会被 +# 读成"这个会话做完了",而工具跑完之后 agent 还在这一轮里。列表只说 +# 会话级的状态。 # # 只产出状态,不产出 message 和 family —— 那两个是 pill 的活("Ran"、 # "Read" 这些动词),列表不需要。 @@ -205,10 +208,9 @@ function Get-SessionState($msg) { if (-not $m) { # ledger 事件格式:{kind, phase, action, ...} $kind = [string]$msg.kind - if ($kind -eq $S_TOOLRESULT) { - if ($msg.isError -eq $true) { return 'error' } - return 'done' - } + # 一个工具跑完了,**这一轮还没结束**:agent 还要接着说话或调下一个 + # 工具。所以这里是 working,不是 done。 + if ($kind -eq $S_TOOLRESULT) { return 'working' } if ($kind -eq 'toolCall' -or $kind -eq $S_TOOLCALL) { if (Test-IsAskUser ([string]$msg.toolName)) { return 'waiting' } return 'working' @@ -240,10 +242,12 @@ function Get-SessionState($msg) { if ($hasText) { return 'idle' } return 'idle' } - if ($role -eq $S_TOOLRESULT) { - if ($m.isError -eq $true) { return 'error' } - return 'done' - } + # 同上:工具跑完了(成不成都一样),这一轮还在继续。 + # + # 成功和失败都不改状态 —— 工具级的成败是 pill 的事(它绿点/红点一闪, + # 后面还跟着"Read""Edited"这类动词,是瞬时反馈)。会话级的状态是 + # "这一轮还在跑",工具失败也一样:agent 会重试、或者换个办法。 + if ($role -eq $S_TOOLRESULT) { return 'working' } return '' } diff --git a/plugins/antianqi/mcode-island/scripts/test-session-tracker.ps1 b/plugins/antianqi/mcode-island/scripts/test-session-tracker.ps1 index 20586b45..b9e38ed2 100644 --- a/plugins/antianqi/mcode-island/scripts/test-session-tracker.ps1 +++ b/plugins/antianqi/mcode-island/scripts/test-session-tracker.ps1 @@ -94,6 +94,17 @@ function Line-Assistant([string]$text) { function Line-ToolCall([string]$tool) { return '{"message":{"role":"assistant","content":[{"type":"toolCall","name":"' + $tool + '","arguments":{}}],"timestamp":3}}' } +# ledger 事件格式:{kind, phase, action, ...} +# +# 真实环境里大多数 session 没有 ledger.jsonl(417 个里只有 1 个),但 +# detector 的 Infer-State 有这个分支,列表也得跟着有 —— 不然同一个会话 +# 在两边的状态会不一样。 +function Line-LedgerToolResult { + return '{"kind":"toolResult","toolName":"bash","ts":1234}' +} +function Line-LedgerToolCall { + return '{"kind":"toolCall","toolName":"bash","action":"doing it","ts":1234}' +} # A tool that failed. The detector reports these as `error`, so the list # has to be able to say it too. function Line-ToolResultFailed { @@ -135,12 +146,22 @@ Check 'assistant 文本结尾 = 待回复' ((Get-SessionState (Get-LastJsonLine Check 'ask_user 结尾 = 等待审批' ((Get-SessionState (Get-LastJsonLine $fAsk)) -eq 'waiting') (Get-SessionState (Get-LastJsonLine $fAsk)) Check 'user 刚说完 = 待回复' ((Get-SessionState (Get-LastJsonLine $fUser)) -eq 'idle') (Get-SessionState (Get-LastJsonLine $fUser)) -# 工具跑完 / 炸了 / 只想不动手:pill 有这几档,列表以前一个都没有。 +# 工具跑完 / 炸了 / 只想不动手。 +# +# 工具跑完(成不成都一样)仍然是"运行中":agent 还在这一轮里,只是刚跑完 +# 一个工具。pill 会闪一下"已完成 + Read",那是瞬时反馈;列表说的是会话 +# 级的状态,说"已完成"会被读成"这个会话做完了"。 $fRes = New-Session 'sess-toolresult' @((Line-ToolResult)) $fResErr = New-Session 'sess-toolerror' @((Line-ToolResultFailed)) $fThink = New-Session 'sess-thinking' @((Line-ThinkingOnly)) -Check 'toolResult 结尾 = 已完成' ((Get-SessionState (Get-LastJsonLine $fRes)) -eq 'done') (Get-SessionState (Get-LastJsonLine $fRes)) -Check '工具失败 = 出错' ((Get-SessionState (Get-LastJsonLine $fResErr)) -eq 'error') (Get-SessionState (Get-LastJsonLine $fResErr)) +Check 'toolResult 结尾 = 运行中(这一轮还没完)' ((Get-SessionState (Get-LastJsonLine $fRes)) -eq 'working') (Get-SessionState (Get-LastJsonLine $fRes)) +Check '工具失败也是运行中(agent 会接着处理)' ((Get-SessionState (Get-LastJsonLine $fResErr)) -eq 'working') (Get-SessionState (Get-LastJsonLine $fResErr)) + +# ledger 事件格式走的是另一个分支,规则一样:工具跑完了,这一轮还没完。 +$fLedgerRes = New-Session 'sess-ledger-result' @((Line-LedgerToolResult)) +$fLedgerCall = New-Session 'sess-ledger-call' @((Line-LedgerToolCall)) +Check 'ledger 的 toolResult 也是运行中' ((Get-SessionState (Get-LastJsonLine $fLedgerRes)) -eq 'working') (Get-SessionState (Get-LastJsonLine $fLedgerRes)) +Check 'ledger 的 toolCall 是运行中' ((Get-SessionState (Get-LastJsonLine $fLedgerCall)) -eq 'working') (Get-SessionState (Get-LastJsonLine $fLedgerCall)) Check 'assistant 只想不动手 = 思考中' ((Get-SessionState (Get-LastJsonLine $fThink)) -eq 'thinking') (Get-SessionState (Get-LastJsonLine $fThink)) # --- 2. 标题提取 --------------------------------------------------------- From a644ab482868945dc39f008941e3ef65b6e9d1db Mon Sep 17 00:00:00 2001 From: antianqi Date: Sat, 24 Oct 2026 08:06:08 +0800 Subject: [PATCH 09/15] feat(mcode-island): clicking a session goes back to its terminal Clicking a session in the list opened a new window running "mcode --session ". The session was already running somewhere, so there were now two terminals for one session: the old one, still going, and the new one, just started. The ask was to bring back the window that was already there. How the session is found Windows Terminal names its tabs " | | MCode" -- the name is set by mcode itself through an ANSI OSC 0 sequence. The pill's own window cannot do the switching: Win32's SetForegroundWindow only activates the window's CURRENT tab, which is not the tab you want. UI Automation can enumerate the tabs and select the right one. Verified by reading the selection back after switching: the target tab was the selected one. The tab's name is whatever the session was called at some moment, while the title the tracker holds is the newest one (the runtime keeps it in its SQLite). They drift apart. So the tracker now records every title a session has had -- the newest one and the first user message, which is usually the name the tab is showing -- and the widget matches against all of them. What happens when it does not match Nothing opens a second terminal. The Terminal window is brought to the front instead and the user finds the tab themselves. A name that no longer matches should cost the user one glance, not a duplicate process. A new terminal is opened only when there is no Terminal window at all to bring forward -- the desktop app's own embedded terminal is like that, and there opening one really is the only way to reach the session. The "ack" that dismisses the row from the list is unchanged: clicking still means "I am dealing with this one now". Validation - scripts/test-session-tracker.ps1 grew from 59 to 61 checks: the title history has to reach sessions.json, and it has to contain the first user message, not just the current title. - The suite on this machine: 61 pass, 0 fail for the tracker, and 133 pass, 0 fail, 1 skipped (platform) for the behavioural suite. - Negative injection, 7 mutations, each one expected to turn a specific check red -- including "the title history is not recorded" -> "sessions.json keeps the title history" goes red. - Live, on this machine, clicking a row and counting the tabs in Windows Terminal before and after: * a session whose name matches a live tab: "session terminal switched", the target tab became the selected one, and the tab count did not change; * a session whose name does not match: "session terminal activated (tab not matched)", the Terminal came forward, and the tab count did not change either. Before this change the same two clicks left 7 and 8 tabs behind where there had been 6. Test evidence - scripts/test-session-tracker.ps1 -> 61 pass, 0 fail - node scripts/test-substep-progress.mjs -> 133 pass, 0 fail, 1 skipped Design compliance - The name matching is a heuristic and is treated as one: it decides which tab, never whether a second terminal appears. - UI Automation is Windows-only and the plugin is Windows-only. If the assemblies or the window are not there, the code falls through instead of failing. - No credentials, no network, no telemetry, no third-party service. - UTF-8 with a BOM, LF line endings: both scripts are dot-sourced or run by Windows PowerShell 5.1 on a zh-CN host. --- .../antianqi/mcode-island/mcode-island.ps1 | 141 ++++++++++++++++++ .../scripts/lib/Session-Tracker.ps1 | 20 +++ .../scripts/test-session-tracker.ps1 | 13 ++ 3 files changed, 174 insertions(+) diff --git a/plugins/antianqi/mcode-island/mcode-island.ps1 b/plugins/antianqi/mcode-island/mcode-island.ps1 index 774a4199..6c667304 100644 --- a/plugins/antianqi/mcode-island/mcode-island.ps1 +++ b/plugins/antianqi/mcode-island/mcode-island.ps1 @@ -682,9 +682,150 @@ function Invoke-SessionClick([string]$id) { Dbg "SESSION ACK FAIL: $($_.Exception.Message)" } Hide-SessionList + # 先试"切到已经开着这个会话的终端 tab"。切到了就不用再开一个 —— + # 新开一个 mcode --session 会让同一个会话有两个终端在跑。 + $names = @() + try { + foreach ($s in @(Read-SessionsFile)) { + if ([string]$s.id -eq $id) { + $names = @(@($s.titles) | Where-Object { $_ }) + if ($names.Count -eq 0 -and $s.title) { $names = @([string]$s.title) } + break + } + } + } catch {} + if (Select-SessionTerminal $names) { + Dbg "session terminal switched: $id" + return + } + # 没找到具体 tab:把终端窗口带到前台,但不要再开一个终端。 + # + # 会话名对不上只该影响"切到哪个标签",不该造成"同一个会话两个终端"。 + # 只有连一个终端窗口都找不到时(桌面端 app 内嵌的终端就是这种), + # 才退回新开 —— 那时候新开是唯一到得了那个会话的办法。 + if (Activate-TerminalWindow) { + Dbg "session terminal activated (tab not matched): $id" + return + } Start-SessionTerminal $id } +# 切到已经开着这个会话的终端 tab。 +# +# 为什么需要这一段:mcode 会话跑在某个终端 tab 里。点列表的时候如果新开 +# 一个 "mcode --session ",同一个会话就有两个终端在跑 —— 旧的还在, +# 新的也起来了。用户要的是回到原来那个,不是再要一个。 +# +# Windows Terminal 的 tab 标题是 "<状态> | <会话名> | MCode",会话名是 +# mcode 自己设的(ANSI OSC 0)。Win32 的 SetForegroundWindow 只能激活到 +# 窗口的**当前活动** tab,切不到指定的 tab;UI Automation 能列出 tab +# 并选中它(实测:选中后回读,活动的确实是目标 tab)。 +# +# 匹配用会话的标题历史。tab 上那个名字是 mcode 在**某个时刻**设的,而 +# 会话标题会随着对话推进变(运行时 SQLite 里存的是最新的),所以要拿 +# 历史上出现过的所有名字一起匹配。 +# +# 匹配不上就退回原来的做法(新开一个)。这里不搞更聪明的:终端标题可能 +# 被程序改过,终端也可能根本不是 Windows Terminal(桌面端 app 内嵌的 +# 终端就不是),这时候新开反而是唯一到得了那个会话的办法。 +$script:uiaReady = $false + +function Select-SessionTerminal([string[]]$names) { + if (-not $names -or $names.Count -eq 0) { return $false } + try { + if (-not $script:uiaReady) { + Add-Type -AssemblyName UIAutomationClient -ErrorAction Stop + Add-Type -AssemblyName UIAutomationTypes -ErrorAction Stop + $script:uiaReady = $true + } + if (-not ('IslandNative' -as [type])) { + # 单行字符串,不写 here-string:外层已经是 here-string 了,嵌套会提前结束。 + Add-Type -Namespace IslandNative -Name Win -MemberDefinition '[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);' -ErrorAction Stop + } + $root = [System.Windows.Automation.AutomationElement]::RootElement + $winCond = New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::ClassNameProperty, 'CASCADIA_HOSTING_WINDOW_CLASS') + $wins = $root.FindAll([System.Windows.Automation.TreeScope]::Children, $winCond) + $tabCond = New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::ControlTypeProperty, + [System.Windows.Automation.ControlType]::TabItem) + foreach ($w in $wins) { + $tabs = $w.FindAll([System.Windows.Automation.TreeScope]::Descendants, $tabCond) + for ($t = 0; $t -lt $tabs.Count; $t++) { + $tab = $tabs.Item($t) + $title = [string]$tab.Current.Name + if (-not $title) { continue } + # "<状态> | <会话名> | MCode" -> 取中间那一段 + $parts = @($title -split '\|') + $tabName = '' + if ($parts.Count -ge 3) { $tabName = $parts[1].Trim() } else { $tabName = $title.Trim() } + $hit = $false + foreach ($n in $names) { + if (-not $n) { continue } + $nn = ([string]$n).Trim() + if (-not $nn) { continue } + if ($tabName -eq $nn -or $title.Contains($nn)) { $hit = $true; break } + } + if (-not $hit) { continue } + try { + $sp = $tab.GetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern) + $sp.Select() + } catch { continue } + # 选中 tab 不一定会把窗口抢到前台,再点一次标题。 + try { + $h = [IntPtr]$w.Current.NativeWindowHandle + [IslandNative.Win]::SetForegroundWindow($h) | Out-Null + } catch {} + return $true + } + } + } catch { + # UI Automation 不可用、终端不是 Windows Terminal、标题对不上…… + # 统统退回原来的做法。 + } + return $false +} + +# 找不到具体 tab 时,至少把终端窗口带到前台,用户自己找那个标签。 +# +# 关键是**不要再开一个**:同一个会话两个终端在跑,正是这个功能要消除的 +# 问题。会话名对不上(终端标题是 mcode 某个时刻起的名字,而会话标题会 +# 变)只影响"切到哪个标签",不该影响"会不会多出一个终端"。 +function Activate-TerminalWindow { + try { + if (-not $script:uiaReady) { + Add-Type -AssemblyName UIAutomationClient -ErrorAction Stop + Add-Type -AssemblyName UIAutomationTypes -ErrorAction Stop + $script:uiaReady = $true + } + if (-not ('IslandNative' -as [type])) { + Add-Type -Namespace IslandNative -Name Win -MemberDefinition '[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);' -ErrorAction Stop + } + $root = [System.Windows.Automation.AutomationElement]::RootElement + $winCond = New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::ClassNameProperty, 'CASCADIA_HOSTING_WINDOW_CLASS') + $tabCond = New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::ControlTypeProperty, + [System.Windows.Automation.ControlType]::TabItem) + foreach ($w in $root.FindAll([System.Windows.Automation.TreeScope]::Children, $winCond)) { + # 优先挑开着 mcode 的那个窗口(tab 标题是 "<状态> | <会话名> | MCode") + $hasMcode = $false + $tabs = $w.FindAll([System.Windows.Automation.TreeScope]::Descendants, $tabCond) + for ($i = 0; $i -lt $tabs.Count; $i++) { + if ([string]$tabs.Item($i).Current.Name -match '\|\s*MCode\s*$') { $hasMcode = $true; break } + } + if (-not $hasMcode) { continue } + try { + $h = [IntPtr]$w.Current.NativeWindowHandle + [IslandNative.Win]::SetForegroundWindow($h) | Out-Null + return $true + } catch {} + } + } catch { + } + return $false +} + # 切到这个 session:mcode --session # # 优先 Windows Terminal(wt.exe);它不在 PATH 时退回 conhost 的 start。 diff --git a/plugins/antianqi/mcode-island/scripts/lib/Session-Tracker.ps1 b/plugins/antianqi/mcode-island/scripts/lib/Session-Tracker.ps1 index 66515324..3dbcd853 100644 --- a/plugins/antianqi/mcode-island/scripts/lib/Session-Tracker.ps1 +++ b/plugins/antianqi/mcode-island/scripts/lib/Session-Tracker.ps1 @@ -444,6 +444,12 @@ function Update-SessionTracker { # 那是 SinceMs;列表右侧显示的是这个。 Preview = (Get-SessionPreview $msg) LastTool = (Get-SessionLastTool $msg) + # 见过的所有标题。终端 tab 上的名字是 mcode 在某个时刻设的,而 + # 会话标题会随对话推进变,所以要留着历史才匹配得上那个 tab。 + # + # 首条用户消息也要放进去:tab 上的名字通常就是会话刚起时那个 + # 名字,而运行时那个标题是后来变的。 + Titles = @(@((Get-SessionDisplayTitle $id $g.Log), (Get-SessionTitle $g.Log)) | Where-Object { $_ } | Select-Object -Unique) LastAtMs = [int64]([DateTimeOffset]::new($g.MTime).ToUnixTimeMilliseconds()) Dir = $g.Dir } @@ -483,6 +489,17 @@ function Update-SessionTracker { $entry.Preview = $newPreview $entry.LastTool = $newTool $entry.LastAtMs = $newAtMs + # 标题变了就记进历史(最多留 8 个,够覆盖 tab 标题那个时刻)。 + # + # 无条件并入当前标题:detector 重启后条目是从 sessions.json 恢复的, + # 恢复出来的标题和当前标题一样,光靠"变了才记"会一个都记不上。 + $newTitle = (Get-SessionDisplayTitle $id $g.Log) + if ($newTitle) { + # 首条用户消息一起并进来:tab 名多半是会话刚起时的那个名字。 + $hist = @(@($entry.Titles) + @($newTitle) + @((Get-SessionTitle $g.Log)) | Where-Object { $_ } | Select-Object -Unique) + if ($hist.Count -gt 8) { $hist = @($hist | Select-Object -Last 8) } + $entry.Titles = $hist + } # 运行时标题可能比列表出现得晚(node 装好了、库能读了)。所以每次更新 # 都再问一次,拿到更好的名字就升级。Get-RuntimeSessionTitle 内部有 60s # 缓存,所以每秒问一次并不会每秒起进程。 @@ -519,6 +536,8 @@ function Write-SessionsFile { preview = $(if ($e.Preview) { [string]$e.Preview } else { '' }) lastTool = $(if ($e.LastTool) { [string]$e.LastTool } else { '' }) lastAtMs = $(if ($e.LastAtMs) { [int64]$e.LastAtMs } else { 0 }) + # 见过的所有标题,widget 拿它去匹配终端 tab。 + titles = @($e.Titles | Where-Object { $_ }) } } $payload = [PSCustomObject]@{ @@ -561,6 +580,7 @@ function Restore-SessionTracker { Preview = $(if ($s.preview) { [string]$s.preview } else { '' }) LastTool = $(if ($s.lastTool) { [string]$s.lastTool } else { '' }) LastAtMs = $(if ($s.lastAtMs) { [int64]$s.lastAtMs } else { 0 }) + Titles = @($s.titles | Where-Object { $_ }) Dir = '' } } diff --git a/plugins/antianqi/mcode-island/scripts/test-session-tracker.ps1 b/plugins/antianqi/mcode-island/scripts/test-session-tracker.ps1 index b9e38ed2..d34117b6 100644 --- a/plugins/antianqi/mcode-island/scripts/test-session-tracker.ps1 +++ b/plugins/antianqi/mcode-island/scripts/test-session-tracker.ps1 @@ -295,6 +295,19 @@ $eRestored = $script:trackedSessions['sess-preview'] Check '重启后 preview 恢复了' ($eRestored.Preview -eq '改完了') ("得到: '" + $eRestored.Preview + "'") Check '重启后 lastAtMs 恢复了' ([int64]$eRestored.LastAtMs -gt 0) ("得到: " + $eRestored.LastAtMs) +# 标题历史。 +# +# 终端 tab 上的名字是 mcode 在**某个时刻**设的,而会话标题会随着对话 +# 推进变。widget 靠这些名字认出"哪个 tab 是这个会话",所以历史里必须有 +# 它:至少要有首条用户消息那一个。 +$fHist = New-Session 'sess-hist' @((Line-User '首条提问是另外一句话')) +Update-SessionTracker +Write-SessionsFile -Force +$histJson = Get-Content (Join-Path $configDir 'sessions.json') -Raw | ConvertFrom-Json +$histRow = @($histJson.sessions | Where-Object { $_.id -eq 'sess-hist' })[0] +Check 'sessions.json 记下了标题历史' (@($histRow.titles).Count -ge 1) ("得到: " + (@($histRow.titles) -join ' | ')) +Check '标题历史里有首条用户消息' (@($histRow.titles) -contains '首条提问是另外一句话') ("得到: " + (@($histRow.titles) -join ' | ')) + # Phase 没变但内容变了,也要落盘。 # # agent 连着跑两个工具时 Phase 一直是 running,摘要和最后活动时间却在变。 From 7b403777b55d6ef8f3ecb90061c6db8abef8319a Mon Sep 17 00:00:00 2001 From: antianqi Date: Sat, 24 Oct 2026 08:45:49 +0800 Subject: [PATCH 10/15] fix(mcode-island): the session title is the session's name again The list showed "I will close the 5h usage chain next. First look at the current state file and the two write paths." where it used to show "review the new replies on MiniMax-Code-Plugins PR 62". The title was not gone -- it had fallen back to the first user message, because the real title could not be read any more. The cause is a PowerShell variable-name trap. Variable names are case-insensitive, so "$home" is the read-only automatic variable $HOME, the user's home directory. Assigning to it throws "VariableNotWritable". The assignment sat inside the try/catch that guards the title read, so the exception was swallowed and the read returned an empty map -- 11 ms, no node process started at all. The tracker then fell back to the first user message, which is a sentence rather than a name. Fixed by not using that name. The read now returns 413 titles in 196 ms, and the session's title is the name again. It was my own bug, introduced while making the read work on a host without USERPROFILE: the fallback path introduced a local $home. It never showed up in the tests, because the tests point the read at a temporary database through MCODE_RUNTIME_STATE_DB and never reach the fallback branch, and because a swallowed exception is invisible from the outside -- the feature simply did not work. Two checks now guard it: - the source of Runtime-Titles.ps1 may not contain "$home" any more; - Get-UserHome has to return something. Neither of them would have caught this on their own. What caught it was the list on screen showing the wrong thing, which is why the title is also read back from the live database after the fix. Validation - scripts/test-session-tracker.ps1 grew from 61 to 63 checks. - The suite on this machine: 63 pass, 0 fail for the tracker, and 133 pass, 0 fail, 1 skipped (platform) for the behavioural suite. - Live: the list now reads "review the new replies on MiniMax-Code-Plugins PR 62", and the title history holds both that name and the earlier sentence. - A scan of every .ps1 in the plugin for assignments to read-only automatic variables ($HOME, $PID, $PROFILE, $HOST, $MATCHES, ...) found no other one. Test evidence - scripts/test-session-tracker.ps1 -> 63 pass, 0 fail - node scripts/test-substep-progress.mjs -> 133 pass, 0 fail, 1 skipped Design compliance - The fallback behaviour is unchanged: when the runtime title cannot be read, the first user message is used. That fallback is what made the bug visible instead of silent. - No new dependency, no network, no credentials. - UTF-8 with a BOM, LF line endings: both scripts are dot-sourced or run by Windows PowerShell 5.1 on a zh-CN host. --- .../mcode-island/scripts/lib/Runtime-Titles.ps1 | 12 ++++++------ .../mcode-island/scripts/test-session-tracker.ps1 | 13 +++++++++++++ 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/plugins/antianqi/mcode-island/scripts/lib/Runtime-Titles.ps1 b/plugins/antianqi/mcode-island/scripts/lib/Runtime-Titles.ps1 index 23923b2e..886ae184 100644 --- a/plugins/antianqi/mcode-island/scripts/lib/Runtime-Titles.ps1 +++ b/plugins/antianqi/mcode-island/scripts/lib/Runtime-Titles.ps1 @@ -67,9 +67,9 @@ function Get-NodeCommand { if ($c) { return @{ Exe = $c.Source; Env = $null } } # CLI 自带:~/.minimax-code/runtime/node-vXX.Y.Z-win-x64/node.exe - $home = Get-UserHome - if ($home) { - $runtimeRoot = Join-Path $home '.minimax-code\runtime' + $userHome = Get-UserHome + if ($userHome) { + $runtimeRoot = Join-Path $userHome '.minimax-code\runtime' if (Test-Path -LiteralPath $runtimeRoot) { $nodeExe = Get-ChildItem -LiteralPath $runtimeRoot -Recurse -Depth 2 -File -Filter 'node.exe' -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 1 @@ -107,9 +107,9 @@ function Read-RuntimeTitles { # 不然就只能读开发机上那个 1.9GB 的真库。 $db = $env:MCODE_RUNTIME_STATE_DB if (-not $db) { - $home = Get-UserHome - if (-not $home) { return $result } - $db = Join-Path $home '.minimax\v2\sqlite\runtime-state.sqlite' + $userHome = Get-UserHome + if (-not $userHome) { return $result } + $db = Join-Path $userHome '.minimax\v2\sqlite\runtime-state.sqlite' } if (-not (Test-Path -LiteralPath $db)) { return $result } diff --git a/plugins/antianqi/mcode-island/scripts/test-session-tracker.ps1 b/plugins/antianqi/mcode-island/scripts/test-session-tracker.ps1 index d34117b6..245502f1 100644 --- a/plugins/antianqi/mcode-island/scripts/test-session-tracker.ps1 +++ b/plugins/antianqi/mcode-island/scripts/test-session-tracker.ps1 @@ -295,6 +295,19 @@ $eRestored = $script:trackedSessions['sess-preview'] Check '重启后 preview 恢复了' ($eRestored.Preview -eq '改完了') ("得到: '" + $eRestored.Preview + "'") Check '重启后 lastAtMs 恢复了' ([int64]$eRestored.LastAtMs -gt 0) ("得到: " + $eRestored.LastAtMs) +# $HOME 是 PowerShell 的只读自动变量,而变量名**不区分大小写**:写 +# "$home = ..." 会直接抛 "VariableNotWritable"。那个异常被读标题的 +# try/catch 吞掉之后,运行时标题就读不出来了 —— 列表里显示成首条消息的 +# 摘要,而不是会话名(用户报的就是这个)。 +# +# 这条断言照着这个坑写的:源码里不许再出现 $home。 +$rtSrcPath = Join-Path $PSScriptRoot 'lib\Runtime-Titles.ps1' +if (Test-Path $rtSrcPath) { + $rtSrc = [System.IO.File]::ReadAllText($rtSrcPath) + Check 'Runtime-Titles 没有用 $home 撞只读的 $HOME' (-not ($rtSrc -match '(?i)\$home\b')) '源码里又出现了 $home' +} +Check 'Get-UserHome 能拿到主目录' ((Get-UserHome) -ne '') '拿不到主目录' + # 标题历史。 # # 终端 tab 上的名字是 mcode 在**某个时刻**设的,而会话标题会随着对话 From e73aa8f513cd49e87fc48ed0d503438e875f7a00 Mon Sep 17 00:00:00 2001 From: antianqi Date: Sat, 24 Oct 2026 08:53:01 +0800 Subject: [PATCH 11/15] fix(mcode-island): injected system messages are not something anyone said The second line of a row showed " Local backend...". The system pushes background-task notifications, task reminders and agent context into the session log as user messages. They are not something anyone said, and they were being used as the summary, and as the title when the runtime title was unavailable. The title and the summary now skip anything that starts with one of those injected tags and keep looking. A session whose last message is only an injected notification simply has no summary, which is honest. Validation - scripts/test-session-tracker.ps1 grew from 63 to 65 checks: an injected message must not become the preview, and the title must not come from one either. - The suite on this machine: 65 pass, 0 fail for the tracker, and 133 pass, 0 fail, 1 skipped (platform) for the behavioural suite. - Negative injection, 2 mutations: N21 the preview stops skipping injected messages -> "an injected message is not the preview" goes red N22 the title stops skipping injected messages -> "the title does not come from an injected message" goes red - Live: the list shows the session's name on the first line, and the second line is empty rather than a wall of XML. Test evidence - scripts/test-session-tracker.ps1 -> 65 pass, 0 fail - node scripts/test-substep-progress.mjs -> 133 pass, 0 fail, 1 skipped Design compliance - Only the injected tags are filtered, and only at the start of the text. A person writing "" still gets a summary. - No new dependency, no network, no credentials. - UTF-8 with a BOM, LF line endings: both scripts are dot-sourced or run by Windows PowerShell 5.1 on a zh-CN host. --- .../scripts/lib/Session-Tracker.ps1 | 25 ++++++++++++++++--- .../scripts/test-session-tracker.ps1 | 14 +++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/plugins/antianqi/mcode-island/scripts/lib/Session-Tracker.ps1 b/plugins/antianqi/mcode-island/scripts/lib/Session-Tracker.ps1 index 3dbcd853..10ef5596 100644 --- a/plugins/antianqi/mcode-island/scripts/lib/Session-Tracker.ps1 +++ b/plugins/antianqi/mcode-island/scripts/lib/Session-Tracker.ps1 @@ -123,6 +123,18 @@ function Get-SessionLogGroups($root) { # 不是"第一行":压缩过的会话(session compaction)第一行是 # role=compactionSummary 或 role=custom,不是 user 消息。所以要往下扫。 # +# 判断一段正文是不是系统注入的消息,而不是用户或 agent 说的话。 +# +# 系统会把后台任务完成通知、任务提醒、agent 上下文这些以 user 角色的 +# 消息塞进会话日志里。它们不是"有人在说话",拿来当标题或摘要,列表上 +# 就会出现一堆 XML 噪声(" Local backend...")。 +function Test-IsSystemInjected($text) { + if (-not $text) { return $true } + $t = ([string]$text).Trim() + if (-not $t) { return $true } + return ($t -match '^<(system-reminder|background-task|task-notification|agent-context|command-|user-message|system-)') +} + # user 消息的正文前面挂着一整块 (agent-context 注入), # 把它剥掉,剩下的才是用户真正说的第一句话。 function Get-SessionTitle($file) { @@ -162,6 +174,8 @@ function Get-SessionTitle($file) { $t = $t -replace '(?s).*?', '' $t = ($t -replace '\s+', ' ').Trim() if (-not $t) { continue } + # 系统注入的 user 消息不是用户说的话,跳过它继续找。 + if (Test-IsSystemInjected $t) { continue } if ($role -eq $S_USER) { $title = $t; break } # 兜底:被压缩过的会话(compaction)里,原始提问已经被 # compactionSummary 替换掉了,真实标题取不回来。agent 的第一句 @@ -296,11 +310,16 @@ function Get-SessionPreview($msg) { if ($role -eq $S_TOOLRESULT) { return '' } $text = '' foreach ($c in @($m.content)) { - if ($c.type -eq 'text' -and $c.text -and -not $text) { $text = [string]$c.text; break } + if ($c.type -ne 'text' -or -not $c.text) { continue } + $candidate = ([string]$c.text) -replace '(?s).*?', '' + $candidate = ($candidate -replace '\s+', ' ').Trim() + if (-not $candidate) { continue } + # 系统注入的消息不算"说了什么",跳过它看下一段。 + if (Test-IsSystemInjected $candidate) { continue } + $text = $candidate + break } if (-not $text) { return '' } - $text = $text -replace '(?s).*?', '' - $text = ($text -replace '\s+', ' ').Trim() return $text } diff --git a/plugins/antianqi/mcode-island/scripts/test-session-tracker.ps1 b/plugins/antianqi/mcode-island/scripts/test-session-tracker.ps1 index 245502f1..e5f4f8ab 100644 --- a/plugins/antianqi/mcode-island/scripts/test-session-tracker.ps1 +++ b/plugins/antianqi/mcode-island/scripts/test-session-tracker.ps1 @@ -115,6 +115,11 @@ function Line-ToolResultFailed { function Line-ThinkingOnly { return '{"message":{"role":"assistant","content":[{"type":"thinking","thinking":"hmm"}],"timestamp":4}}' } +# 系统注入的消息:后台任务完成通知、系统提醒这些,是以 user 角色塞进 +# 会话日志的,但它们不是"有人在说话"。 +function Line-SystemNoise { + return '{"message":{"role":"user","content":[{"type":"text","text":" noise from the system"}],"timestamp":4}}' +} function Line-ToolResult { return '{"message":{"role":"toolResult","content":[{"type":"text","text":"ok"}],"timestamp":4}}' } @@ -295,6 +300,15 @@ $eRestored = $script:trackedSessions['sess-preview'] Check '重启后 preview 恢复了' ($eRestored.Preview -eq '改完了') ("得到: '" + $eRestored.Preview + "'") Check '重启后 lastAtMs 恢复了' ([int64]$eRestored.LastAtMs -gt 0) ("得到: " + $eRestored.LastAtMs) +# 系统注入的消息不能当摘要。 +# +# 系统把后台任务完成通知这些以 user 角色塞进日志。它们不是人话,拿来当 +# 摘要,列表第二行就会显示 " Local backend..."。 +$fNoise = New-Session 'sess-noise' @((Line-SystemNoise)) +$noiseMsg = Get-LastJsonLine $fNoise +Check '系统注入的消息不进 preview' ([string]::IsNullOrEmpty((Get-SessionPreview $noiseMsg))) ("得到: '" + (Get-SessionPreview $noiseMsg) + "'") +Check '标题也不取系统注入的那条' (-not ((Get-SessionTitle $fNoise) -match 'background-task')) ("得到: '" + (Get-SessionTitle $fNoise) + "'") + # $HOME 是 PowerShell 的只读自动变量,而变量名**不区分大小写**:写 # "$home = ..." 会直接抛 "VariableNotWritable"。那个异常被读标题的 # try/catch 吞掉之后,运行时标题就读不出来了 —— 列表里显示成首条消息的 From 2a45bbcf4e541a6f2a4981abe3cd26d312a441b1 Mon Sep 17 00:00:00 2001 From: antianqi Date: Sat, 24 Oct 2026 09:06:43 +0800 Subject: [PATCH 12/15] fix(mcode-island): the last row of the session list is not cut in half The bottom row of the list was clipped -- half a row of it, with empty space below it inside the window, which made it look even more like a drawing mistake than a layout one. The height was computed rather than measured. The list was given "rows x 48px", and the window was given that plus a constant. Two things were left out: - SessionPanel carries a 14px top margin, 12px of padding and a 1px border of its own -- 28px that never showed up in the arithmetic; - a row is 46px, not the 48 that was assumed. With two rows the content needed 106px, the row was given 94px, and the last row lost 12px. The window still had 14px of slack at the bottom, because that 14px was the margin, which lives outside the content. The height is now measured instead of guessed: row 1 is set to Auto, the layout is forced, and the window is sized from the panel's DesiredSize plus the margin. That is the height the content actually asks for, and it stays right when a row is taller than usual. Two comments in this file had been carrying a replacement character -- text that had been mangled when it was written. One of them ("the verified ... of it") was left over from an earlier change and was never looked at; the other came with this one. Both are fixed here, and the whole plugin was scanned: no replacement characters are left. Validation - The behavioural suite: 133 pass, 0 fail, 1 skipped (platform). - Live, on this machine: with two sessions in the list the window is now 205px tall instead of 192px, the last row's bottom sits at 191px and the window's bottom at 219px -- the row is whole, with 28px of slack instead of a clipped half row. - The layout is measured on screen, so there is no unit test for it here; the numbers above are the measurement. Test evidence - node scripts/test-substep-progress.mjs -> 133 pass, 0 fail, 1 skipped Design compliance - The pill itself does not move: row 0 stays locked to the pill height, and the list grows below it, exactly as before. - The 300ms collapse grace period is untouched. - UTF-8 with a BOM, LF line endings: the file is dot-sourced or run by Windows PowerShell 5.1 on a zh-CN host. --- .../antianqi/mcode-island/mcode-island.ps1 | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/plugins/antianqi/mcode-island/mcode-island.ps1 b/plugins/antianqi/mcode-island/mcode-island.ps1 index 6c667304..018467ae 100644 --- a/plugins/antianqi/mcode-island/mcode-island.ps1 +++ b/plugins/antianqi/mcode-island/mcode-island.ps1 @@ -397,7 +397,7 @@ $stateMap = @{ # 采样点只有 39 个是面板色,其余全是它背后的东西。隔离环境里同样的 # 代码却能画出来,所以问题出在 widget 的上下文里。 # 同一个窗口就没有这个问题:置顶、合成、命中测试全都走 pill 已经验证 -# 过的��一套。 +# 过的那一套。 # # 代价是窗口要变高。row 0 的高度锁死为药丸高度,所以展开时药丸本身一动 # 不动,列表在它下面长出来。鼠标从药丸移到列表不用离开窗口,也没有那个 @@ -632,12 +632,22 @@ function Show-SessionList { Add-SessionRow ([string]$s.id) ([string]$s.title) ([string]$s.phase) ([string]$s.preview) ([string]$s.lastTool) ([int64]$s.lastAtMs) ($i -lt ($sorted.Count - 1)) $i++ } - # 列表高度 = 行数 * 行高 + 上下 padding;再给药丸的投影留一点余量。 - # 行高是两项(标题 + 摘要)加内边距,不是原来单行的那 24。 - $listH = ($sorted.Count * $script:sessionRowHeight) + 12 + # 列表高度不能按"行数 × 猜的行高"算。 + # + # 那样算有两处错:摘要会换行,一项不止那么高;而且 SessionPanel 自己 + # 还要占 Margin 14 + Padding 12 + Border 2。两行时内容需要 106px,row + # 只分到 94px,最后一行就被裁掉半行 —— 窗口底部还空着 14px,看起来 + # 更莫名其妙。 + # + # 所以让布局自己算:row 1 改成 Auto,Grid 就按内容测量,再拿实际需要 + # 的高度去撑窗口。DesiredSize 是"内容需要多高",不受窗口当前高度影响。 $script:sessionRowsPanel.Visibility = [System.Windows.Visibility]::Visible - $script:sessionListRow.Height = New-Object System.Windows.GridLength($listH) - $window.Height = $script:pillHeight + $listH + 14 + $script:sessionListRow.Height = [System.Windows.GridLength]::Auto + $script:sessionRowsPanel.UpdateLayout() + # DesiredSize 里没有 Margin,所以自己加上 SessionPanel 的 14px 上边距。 + $listH = [int][Math]::Ceiling($script:sessionRowsPanel.DesiredSize.Height) + 14 + if ($listH -le 0) { $listH = ($sorted.Count * $script:sessionRowHeight) + 26 } + $window.Height = $script:pillHeight + $listH Dbg ("session list shown: " + $sorted.Count + " winH=" + $window.Height + " listH=" + $listH) } From 633ceae6dc5bc059e78472a6276bf28580db7154 Mon Sep 17 00:00:00 2001 From: antianqi Date: Sat, 24 Oct 2026 10:01:25 +0800 Subject: [PATCH 13/15] fix(mcode-island): the terminal actually comes to the front Clicking a session switched the tab but nothing appeared: the window stayed behind whatever the user was looking at. The cause is the foreground lock. SetForegroundWindow is refused for a process that is not already in the foreground, and the widget is one. The tab had switched -- the title bar said so -- but the window never came forward, so from the user's side the click did nothing. The widget already had this solved for the pill's show/hide toggle: set the window TOPMOST, then NOTOPMOST again, which lets BringWindowToTop push it all the way up, and only then SetForegroundWindow. The new code calls that same routine instead of calling SetForegroundWindow on its own. The log now says which process is in the foreground afterwards, so a switch that did not take is visible instead of silent. Validation - Live, on this machine: the foreground window before the click was the desktop app (pid 2496); after the click it was WindowsTerminal.exe (pid 59084). The window came forward. - The behavioural suite: 133 pass, 0 fail, 1 skipped (platform). Test evidence - node scripts/test-substep-progress.mjs -> 133 pass, 0 fail, 1 skipped Design compliance - The pill's own focus path is untouched; this reuses it. - No new dependency, no network, no credentials. - UTF-8 with a BOM, LF line endings: the file is dot-sourced or run by Windows PowerShell 5.1 on a zh-CN host. --- .../antianqi/mcode-island/mcode-island.ps1 | 57 +++++++++++++++---- 1 file changed, 47 insertions(+), 10 deletions(-) diff --git a/plugins/antianqi/mcode-island/mcode-island.ps1 b/plugins/antianqi/mcode-island/mcode-island.ps1 index 018467ae..15048770 100644 --- a/plugins/antianqi/mcode-island/mcode-island.ps1 +++ b/plugins/antianqi/mcode-island/mcode-island.ps1 @@ -96,6 +96,7 @@ public class WinAPI { [DllImport("user32.dll")] public static extern int GetWindowText(IntPtr hWnd, System.Text.StringBuilder lpString, int nMaxCount); [DllImport("user32.dll")] public static extern int GetWindowTextLength(IntPtr hWnd); [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId); + [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); [DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc lpEnumFunc, IntPtr lParam); [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr hWnd); [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect); @@ -705,7 +706,7 @@ function Invoke-SessionClick([string]$id) { } } catch {} if (Select-SessionTerminal $names) { - Dbg "session terminal switched: $id" + Dbg ("session terminal switched: " + $id + " foregroundPid=" + (Get-ForegroundPid)) return } # 没找到具体 tab:把终端窗口带到前台,但不要再开一个终端。 @@ -714,12 +715,48 @@ function Invoke-SessionClick([string]$id) { # 只有连一个终端窗口都找不到时(桌面端 app 内嵌的终端就是这种), # 才退回新开 —— 那时候新开是唯一到得了那个会话的办法。 if (Activate-TerminalWindow) { - Dbg "session terminal activated (tab not matched): $id" + Dbg ("session terminal activated (tab not matched): " + $id + " foregroundPid=" + (Get-ForegroundPid)) return } Start-SessionTerminal $id } +# 当前前台窗口是哪个进程。 +# +# 切了 tab 但没抢到前台,肉眼看不出来,只能问系统。 +# +# 记 pid 而不是标题:Windows Terminal 的前台窗口是它的 XAML 子窗口, +# 标题是空的,pid 才是 WindowsTerminal.exe。 +function Get-ForegroundPid { + try { + $fg = [WinAPI]::GetForegroundWindow() + if ($fg -eq [IntPtr]::Zero) { return 0 } + $pid = [uint32]0 + [void][WinAPI]::GetWindowThreadProcessId($fg, [ref]$pid) + return [int]$pid + } catch { return 0 } +} + +# 把终端窗口抢到前台。 +# +# 主 pill 的"显示/隐藏终端"早就解决过这个问题,用的是既有那套聚焦:先 +# TOPMOST 再 NOTOPMOST(这样 BringWindowToTop 才推得动),然后 +# SetForegroundWindow。直接复用,不另造一套。 +# +# 裸的 SetForegroundWindow 不够:调用方是后台进程时,Windows 的前台锁会 +# 挡下来 —— 窗口切了 tab,用户却看不到变化。 +function Focus-TerminalWindow([IntPtr]$h) { + if (-not $h -or $h -eq [IntPtr]::Zero) { return $false } + try { + $pid = [uint32]0 + [void][WinAPI]::GetWindowThreadProcessId($h, [ref]$pid) + Focus-Window -Target @{ Hwnd = $h; Pid = $pid; Exe = 'WindowsTerminal' } + return $true + } catch { + return $false + } +} + # 切到已经开着这个会话的终端 tab。 # # 为什么需要这一段:mcode 会话跑在某个终端 tab 里。点列表的时候如果新开 @@ -781,11 +818,13 @@ function Select-SessionTerminal([string[]]$names) { $sp = $tab.GetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern) $sp.Select() } catch { continue } - # 选中 tab 不一定会把窗口抢到前台,再点一次标题。 - try { - $h = [IntPtr]$w.Current.NativeWindowHandle - [IslandNative.Win]::SetForegroundWindow($h) | Out-Null - } catch {} + # 选中 tab 不会把窗口带到前台,而裸的 SetForegroundWindow 又会被 + # Windows 的前台锁挡下(调用方是后台进程)—— 实测窗口切了 tab, + # 用户却看不到任何变化。 + # + # 所以复用主 pill 的聚焦:先 TOPMOST 再 NOTOPMOST,让 + # BringWindowToTop 能把窗口推到最前,最后 SetForegroundWindow。 + Focus-TerminalWindow ([IntPtr]$w.Current.NativeWindowHandle) return $true } } @@ -826,9 +865,7 @@ function Activate-TerminalWindow { } if (-not $hasMcode) { continue } try { - $h = [IntPtr]$w.Current.NativeWindowHandle - [IslandNative.Win]::SetForegroundWindow($h) | Out-Null - return $true + if (Focus-TerminalWindow ([IntPtr]$w.Current.NativeWindowHandle)) { return $true } } catch {} } } catch { From f9c98da092b60ea1b90b3251f29ffa5ed976ce96 Mon Sep 17 00:00:00 2001 From: antianqi Date: Sat, 24 Oct 2026 10:43:22 +0800 Subject: [PATCH 14/15] fix(mcode-island): a session click no longer loses the foreground Clicking a row in the session list switched the terminal tab but never brought the terminal window to the front. From the user's side nothing happened. Root cause: `$pid = [uint32]0` in Focus-TerminalWindow and in Get-ForegroundPid. PowerShell variable names are case-insensitive, so `$pid` IS the read-only automatic `$PID`; the assignment throws "Cannot overwrite variable PID because it is read-only or constant". Both call sites sit inside a `try { } catch { }`, so the exception was swallowed: Focus-TerminalWindow returned $false before it ever called Focus-Window, and Get-ForegroundPid always returned 0. The caller ignored the return value and logged "session terminal switched: ... foregroundPid=0" anyway, so the log looked like a success. The same class of bug already bit `$home` in Runtime-Titles and `$args` in the detector. It is worth a guard, not just a rename. Fix: * rename the variable to $procId in both functions * read the foreground window back after every focus attempt (Test-ForegroundPid) and log FOCUS CONFIRMED / FOCUS NOT CONFIRMED. SetForegroundWindow fails silently, so a log line can never be the evidence that the window came forward * Focus-TerminalWindow falls back to the z-order recipe the pill's own toggle already uses (SetWindowPos HWND_TOP without SWP_NOACTIVATE, which activates the window) * a failed focus must never fall back to opening a second terminal: Select-SessionTerminal and Activate-TerminalWindow now report the terminal window as found even when the foreground could not be confirmed, so one session never ends up with two terminals again * Focus-Window's log line says DONE instead of OK -- it only means the calls did not throw Test evidence: * node scripts/smoke.mjs -> 153 pass / 0 fail * node scripts/test-substep-progress.mjs -> 133 pass / 0 fail / 1 skipped * pwsh -File scripts/test-session-tracker.ps1 -> 65 pass / 0 fail * pwsh -File scripts/test-windows-workflow-local.ps1 -> all 5 steps OK * new guard in smoke.mjs fails the build on any assignment to a read-only PowerShell automatic variable ($pid, $home, $host, ...), including inside a param() block. Negative injections N23-N27 make it fail, N28-N31 (comment / string / here-string / the `$null =` discard) must not fire, and the tree restores byte-identical. * real-machine end-to-end with a real mouse_event LEFTDOWN/LEFTUP on the session row: with this build the foreground window becomes WindowsTerminal.exe and the selected tab is the clicked session. With the previous build (the parent commit) the foreground stays on the app (pid 2496) and the same check fails. The log now shows "FOCUS CONFIRMED: pid=59084" followed by "session terminal switched: ... foregroundPid=59084", where it used to say foregroundPid=0. Design compliance: * one commit, one thing * reuses the existing Focus-Window / Toggle-Window recipe instead of introducing a third way to steal the foreground * a session click never opens a new terminal window --- .../antianqi/mcode-island/mcode-island.ps1 | 67 +++++++-- .../antianqi/mcode-island/scripts/smoke.mjs | 137 ++++++++++++++++++ 2 files changed, 194 insertions(+), 10 deletions(-) diff --git a/plugins/antianqi/mcode-island/mcode-island.ps1 b/plugins/antianqi/mcode-island/mcode-island.ps1 index 15048770..2e27b40d 100644 --- a/plugins/antianqi/mcode-island/mcode-island.ps1 +++ b/plugins/antianqi/mcode-island/mcode-island.ps1 @@ -731,12 +731,28 @@ function Get-ForegroundPid { try { $fg = [WinAPI]::GetForegroundWindow() if ($fg -eq [IntPtr]::Zero) { return 0 } - $pid = [uint32]0 - [void][WinAPI]::GetWindowThreadProcessId($fg, [ref]$pid) - return [int]$pid + # 变量名不能叫 $pid:PowerShell 的变量名不区分大小写,$PID 是只读的 + # 自动变量,赋值直接抛 "Cannot overwrite variable PID"。而这里外面还 + # 包着 catch —— 异常被吞掉,表现是"这个函数永远返回 0",从外面看 + # 一点异常都看不出来。同名的坑 $HOME 也踩过一次。 + $procId = [uint32]0 + [void][WinAPI]::GetWindowThreadProcessId($fg, [ref]$procId) + return [int]$procId } catch { return 0 } } +# 前台窗口现在是不是目标进程。 +# +# SetForegroundWindow 失败是**静默**的:返回值不告诉你成没成。所以每次抢完 +# 前台都要回读一次前台窗口的 pid 跟目标 pid 对一下 —— 对不上就是没抢到。 +# 不回读的话,日志里一句 "switched" 会把"没抢到"盖过去。 +function Test-ForegroundPid([uint32]$procId) { + $fg = Get-ForegroundPid + if ($fg -eq [int]$procId) { return $true } + Dbg ("FOCUS CHECK: want pid=" + $procId + " foregroundPid=" + $fg) + return $false +} + # 把终端窗口抢到前台。 # # 主 pill 的"显示/隐藏终端"早就解决过这个问题,用的是既有那套聚焦:先 @@ -748,11 +764,33 @@ function Get-ForegroundPid { function Focus-TerminalWindow([IntPtr]$h) { if (-not $h -or $h -eq [IntPtr]::Zero) { return $false } try { - $pid = [uint32]0 - [void][WinAPI]::GetWindowThreadProcessId($h, [ref]$pid) - Focus-Window -Target @{ Hwnd = $h; Pid = $pid; Exe = 'WindowsTerminal' } - return $true + # 变量名不能叫 $pid —— $PID 是只读自动变量,赋值会抛异常,被这里的 + # catch 吞掉,于是"抢前台"这步从来没发生过,而外面看着一切正常。 + $procId = [uint32]0 + [void][WinAPI]::GetWindowThreadProcessId($h, [ref]$procId) + Focus-Window -Target @{ Hwnd = $h; Pid = $procId; Exe = 'WindowsTerminal' } + if (Test-ForegroundPid $procId) { + Dbg ("FOCUS CONFIRMED: pid=" + $procId + " hwnd=" + $h) + return $true + } + # 第一套没抢到:换主 pill 真正在用的那套。 + # + # Focus-Window 里两次 SetWindowPos 都带 SWP_NOACTIVATE,只动 z-order + # 不激活;主 pill 的 Toggle-Window 用的是 SetWindowPos(HWND_TOP) 且 + # **不**带 SWP_NOACTIVATE —— 那个会顺手把窗口激活,这才是"到前台" + # 的关键。用同一套,别自创第四种。 + $flags = [WinAPI]::SWP_NOSIZE -bor [WinAPI]::SWP_NOMOVE + [WinAPI]::SetWindowPos($h, [WinAPI]::HWND_TOP, 0, 0, 0, 0, $flags) | Out-Null + [WinAPI]::BringWindowToTop($h) | Out-Null + [WinAPI]::SetForegroundWindow($h) | Out-Null + if (Test-ForegroundPid $procId) { + Dbg ("FOCUS CONFIRMED (z-order): pid=" + $procId + " hwnd=" + $h) + return $true + } + Dbg ("FOCUS NOT CONFIRMED: want pid=" + $procId + " foregroundPid=" + (Get-ForegroundPid)) + return $false } catch { + Dbg ("FOCUS FAIL: " + $_.Exception.Message) return $false } } @@ -824,7 +862,11 @@ function Select-SessionTerminal([string[]]$names) { # # 所以复用主 pill 的聚焦:先 TOPMOST 再 NOTOPMOST,让 # BringWindowToTop 能把窗口推到最前,最后 SetForegroundWindow。 - Focus-TerminalWindow ([IntPtr]$w.Current.NativeWindowHandle) + # tab 已经切过去了。即使抢前台没成功,也**不能**回退去新开一个终端 + # —— 那正是"同一个会话两个终端"的病根。抢前台失败只记日志。 + if (-not (Focus-TerminalWindow ([IntPtr]$w.Current.NativeWindowHandle))) { + Dbg ("session tab selected but foreground not confirmed: " + ($names -join ' | ')) + } return $true } } @@ -865,7 +907,10 @@ function Activate-TerminalWindow { } if (-not $hasMcode) { continue } try { - if (Focus-TerminalWindow ([IntPtr]$w.Current.NativeWindowHandle)) { return $true } + # 找到终端窗口就算"找到了"。抢前台没成功**不能**返回 $false —— + # 调用方拿 $false 会新开一个终端,同一个会话就多出一个终端了。 + Focus-TerminalWindow ([IntPtr]$w.Current.NativeWindowHandle) | Out-Null + return $true } catch {} } } catch { @@ -1397,7 +1442,9 @@ function Focus-Window { [WinAPI]::SetWindowPos($Target.Hwnd, [IntPtr]::new(-2), 0, 0, 0, 0, [WinAPI]::SWP_NOACTIVATE) | Out-Null # HWND_NOTOPMOST [WinAPI]::BringWindowToTop($Target.Hwnd) | Out-Null [WinAPI]::SetForegroundWindow($Target.Hwnd) | Out-Null - Dbg ("FOCUS OK: target=" + $Target.Exe + " PID=" + $Target.Pid + " hwnd=" + $Target.Hwnd) + # "DONE" 而不是 "OK":这里只说明这几个调用没抛异常,不代表窗口真到了 + # 前台。到底到没到,看 Test-ForegroundPid 的回读。 + Dbg ("FOCUS DONE: target=" + $Target.Exe + " PID=" + $Target.Pid + " hwnd=" + $Target.Hwnd) } catch { Dbg "FOCUS FAIL: $($_.Exception.Message)" } diff --git a/plugins/antianqi/mcode-island/scripts/smoke.mjs b/plugins/antianqi/mcode-island/scripts/smoke.mjs index 2b3eb669..a86c0778 100644 --- a/plugins/antianqi/mcode-island/scripts/smoke.mjs +++ b/plugins/antianqi/mcode-island/scripts/smoke.mjs @@ -1592,6 +1592,143 @@ const main = async () => { out('PASS', `ps1 encoding: all ${nonAsciiCount} non-ASCII .ps1 file(s) carry a UTF-8 BOM (${ps1Files.length} scanned)`); } + // --- PowerShell read-only automatic variables ------------------------ + // + // Assigning to one of these throws "Cannot overwrite variable PID + // because it is read-only or constant". PowerShell variable names are + // case-insensitive, so `$pid` IS the automatic `$PID` -- and a `catch` + // wrapped around the assignment swallows the error, which makes the + // function look like it ran while it never did. + // + // This is not hypothetical. Focus-TerminalWindow assigned to `$pid`, so + // every click on a session row threw, the catch ate it, and the log + // said "session terminal switched" while the terminal never came + // forward. The same class of bug already bit `$home` in Runtime-Titles + // and `$args` in the detector. + // + // The scanner strips comments and string literals first, so a `$pid` + // mentioned in prose, in a here-string, or in a log message is not a + // hit. `$null = ` is a legal discard and `$Error` / `$args` / + // `$OutputEncoding` are writable, so none of them are in the set. + const RESERVED_PS_VARS = new Set([ + 'pid', 'home', 'pshome', 'psversiontable', 'host', 'iswindows', + 'islinux', 'ismacos', 'psculture', 'psuiculture', 'shellid', + 'myinvocation', 'enabledexperimentalfeatures', 'true', 'false', + ]); + + // Remove comments and string literals, keeping the line structure so + // hits can still be reported with a line number. PowerShell strings + // are single- or double-quoted and escape the quote by doubling it; + // here-strings (@'...'@ / @"..."@) span lines. + const stripLiterals = (src) => { + const outLines = []; + let inHere = null; + for (const line of src.split(/\r?\n/)) { + if (inHere) { + const trimmed = line.trim(); + if (trimmed.startsWith(inHere + '@')) inHere = null; + outLines.push(''); + continue; + } + let res = ''; + let i = 0; + while (i < line.length) { + const ch = line[i]; + if (ch === '#') break; // comment to EOL + if (ch === '@' && (line[i + 1] === "'" || line[i + 1] === '"')) { + const q = line[i + 1]; + if (line.slice(i + 2).trim() === '') { + // multi-line here-string opener + inHere = q; + i = line.length; + continue; + } + i++; // single-line form + continue; + } + if (ch === "'" || ch === '"') { + const q = ch; + i++; + while (i < line.length) { + if (line[i] === q) { + if (line[i + 1] === q) { i += 2; continue; } + i++; + break; + } + if (q === '"' && line[i] === '`') { i += 2; continue; } + i++; + } + res += '""'; + continue; + } + res += ch; + i++; + } + outLines.push(res); + } + return outLines; + }; + + // Parameters that shadow a read-only automatic variable. `param($pid)` + // does not throw at definition time but throws the moment the + // parameter is bound, so it has to be caught statically too. + const findReservedParams = (code) => { + const hits = []; + const re = /\bparam\s*\(/gi; + let m; + while ((m = re.exec(code)) !== null) { + let depth = 1; + let i = m.index + m[0].length; + while (i < code.length && depth > 0) { + if (code[i] === '(') depth++; + else if (code[i] === ')') depth--; + i++; + } + const body = code.slice(m.index + m[0].length, i - 1); + const nm = /\$([A-Za-z_][A-Za-z0-9_]*)/g; + let p; + while ((p = nm.exec(body)) !== null) { + const name = p[1]; + if (!RESERVED_PS_VARS.has(name.toLowerCase())) continue; + let k = p.index - 1; + while (k >= 0 && /\s/.test(body[k])) k--; + if (k >= 0 && body[k] === '=') continue; // default value, not a name + const line = code.slice(0, m.index + p.index).split(/\r?\n/).length; + hits.push({ line, name }); + } + } + return hits; + }; + + const ASSIGN_RE = /(?=\w$:])\$(?:(?:script|global|local|private|using):)*([A-Za-z_][A-Za-z0-9_]*)\s*=(?!=)/g; + const BRACED_ASSIGN_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}\s*=(?!=)/g; + + let reservedHits = 0; + for (const abs of ps1Files) { + const rel = abs.slice(PLUGIN_ROOT.length + 1); + const code = stripLiterals(await readFile(abs, 'utf8')).join('\n'); + const lines = code.split('\n'); + for (let i = 0; i < lines.length; i++) { + for (const re of [ASSIGN_RE, BRACED_ASSIGN_RE]) { + re.lastIndex = 0; + let m; + while ((m = re.exec(lines[i])) !== null) { + const name = m[1]; + if (!RESERVED_PS_VARS.has(name.toLowerCase())) continue; + out('FAIL', `${rel}:${i + 1}: assigns to $${name}, a read-only PowerShell automatic variable -- the assignment throws and a surrounding catch swallows it`); + reservedHits++; + } + } + } + for (const hit of findReservedParams(code)) { + out('FAIL', `${rel}:${hit.line}: parameter $${hit.name} shadows the read-only PowerShell automatic variable`); + reservedHits++; + } + } + if (reservedHits === 0) { + out('PASS', `ps1 reserved-variable guard: no assignment to a read-only PowerShell automatic variable (${ps1Files.length} scanned)`); + } + finish(); }; From 0c22cc783f029d72f60ec48d95b18a27d62fe328 Mon Sep 17 00:00:00 2001 From: antianqi Date: Sat, 24 Oct 2026 11:33:30 +0800 Subject: [PATCH 15/15] fix(mcode-island): the terminal comes back full-screen, not as a strip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking a session row brought the terminal forward, but as a ~480x84 strip in the top-left corner of the screen instead of a full-screen window. The user reported it with a screenshot: "点完它是在左上角小小 的一条,而不是全屏". Root cause: Focus-Window's two SetWindowPos calls pass cx=0/cy=0 and X=0/Y=0 as a shorthand for "keep the current size / position", but that shorthand only holds when the matching flag is present. The flags were SWP_NOACTIVATE alone: SetWindowPos(hwnd, HWND_TOPMOST, 0, 0, 0, 0, SWP_NOACTIVATE) SetWindowPos(hwnd, HWND_NOTOPMOST, 0, 0, 0, 0, SWP_NOACTIVATE) Windows therefore read them as "resize to 0x0" and "move to (0,0)". Windows Terminal clamps a 0x0 request to its minimum size, which is the 480x84 strip, and the window really moved to the top-left corner. The window's restored ("normal") placement then stayed 480x84@(0,0), so every later restore brought the strip back. The bug was latent until the previous commit made Focus-TerminalWindow actually reach Focus-Window -- before that, the $PID collision threw first and the whole focus step never ran. This file already documents the trap in Toggle-Window ("SWP_NOSIZE: cx=0/cy=0 缺它会被 Windows 当成 resize 到 0x0, 触发 WT 的 min-size 兜底, 变成 480x76 strip"), it just was never applied to Focus-Window. Fix: * Focus-Window's SetWindowPos calls now carry SWP_NOSIZE | SWP_NOMOVE (plus SWP_NOACTIVATE), so they only touch the z-order * Focus-Window no longer calls SW_SHOW on a window that is already visible -- pointless state churn on an already-maximized window * "show it full-screen" now lives in one helper, Show-WindowFullScreen, used by both the pill's toggle and the session-list click, so the two paths cannot drift apart again * the helper runs SW_RESTORE -> SetWindowPos(work area) -> SW_MAXIMIZE, in that order. The SetWindowPos has to happen while the window is in its normal state: on an already-maximized window Windows does not update the restored size, so a stale size (the 480x84 strip) would survive the next restore. SetWindowPlacement was tried and does not work on Windows Terminal -- the rcNormalPosition it is given reads back as 0x0. Test evidence: * node scripts/smoke.mjs -> 154 pass / 0 fail * node scripts/test-substep-progress.mjs -> 133 pass / 0 fail / 1 skipped * pwsh -File scripts/test-session-tracker.ps1 -> 65 pass / 0 fail * pwsh -File scripts/test-windows-workflow-local.ps1 -> all 5 steps OK * new guard in smoke.mjs fails the build on any SetWindowPos that passes cx=0/cy=0 without SWP_NOSIZE, or X=0/Y=0 without SWP_NOMOVE. Negative injections N32-N34 fire it; N35/N38 (an explicit size with SWP_NOMOVE, work-area coordinates as variables) must not; the tree restores byte-identical. * the round-15/16 drift locks moved with the implementation: they now check Show-WindowFullScreen and both of its callers instead of the text inside Toggle-Window. Negative injection N36 (SW_MAXIMIZE regressed to SW_SHOW) and N37 (the session click stops full-screening) fire them. * real-machine end-to-end, real mouse_event LEFTDOWN/LEFTUP on the session row: with this build the foreground window is Windows Terminal, its rect is 2576x1408 (covers the 2560x1392 work area) and its restored placement is 2560x1392 -- a real full-screen size. With the previous build the foreground stays on the app and the window is a strip; the same check fails. Design compliance: * one commit, one thing * reuses the existing Toggle-Window full-screen recipe instead of introducing a fourth way to raise a window * a session click never opens a new terminal window --- .../antianqi/mcode-island/mcode-island.ps1 | 81 ++++++-- .../antianqi/mcode-island/scripts/smoke.mjs | 176 +++++++++++++++--- 2 files changed, 218 insertions(+), 39 deletions(-) diff --git a/plugins/antianqi/mcode-island/mcode-island.ps1 b/plugins/antianqi/mcode-island/mcode-island.ps1 index 2e27b40d..072c3c26 100644 --- a/plugins/antianqi/mcode-island/mcode-island.ps1 +++ b/plugins/antianqi/mcode-island/mcode-island.ps1 @@ -768,6 +768,13 @@ function Focus-TerminalWindow([IntPtr]$h) { # catch 吞掉,于是"抢前台"这步从来没发生过,而外面看着一切正常。 $procId = [uint32]0 [void][WinAPI]::GetWindowThreadProcessId($h, [ref]$procId) + # 先把终端撑成满屏,再抢前台。 + # + # 顺序不能反:Focus-Window 里对最小化窗口走的 SW_RESTORE 用的是"上次 + # 正常尺寸",而那个尺寸可能已经被写坏(0x0 -> WT 的 min-size 兜底 -> + # 480x84 的一条,还被挪到了 (0,0))。先撑满屏,顺手把那个坏尺寸修 + # 回来,用户看到的才是满屏的终端,而不是左上角一条。 + Show-WindowFullScreen $h | Out-Null Focus-Window -Target @{ Hwnd = $h; Pid = $procId; Exe = 'WindowsTerminal' } if (Test-ForegroundPid $procId) { Dbg ("FOCUS CONFIRMED: pid=" + $procId + " hwnd=" + $h) @@ -779,6 +786,7 @@ function Focus-TerminalWindow([IntPtr]$h) { # 不激活;主 pill 的 Toggle-Window 用的是 SetWindowPos(HWND_TOP) 且 # **不**带 SWP_NOACTIVATE —— 那个会顺手把窗口激活,这才是"到前台" # 的关键。用同一套,别自创第四种。 + # SWP_NOSIZE / SWP_NOMOVE 同样不能少:少了就是"缩到 0x0 + 挪到 (0,0)"。 $flags = [WinAPI]::SWP_NOSIZE -bor [WinAPI]::SWP_NOMOVE [WinAPI]::SetWindowPos($h, [WinAPI]::HWND_TOP, 0, 0, 0, 0, $flags) | Out-Null [WinAPI]::BringWindowToTop($h) | Out-Null @@ -1423,6 +1431,43 @@ function Resolve-ClickTarget { return $null } +# 把窗口撑到所在显示器的 work area(满屏)。 +# +# 为什么不能只靠 SW_RESTORE 还原:还原用的是"上次正常尺寸",而那个尺寸可能 +# 已经被写坏。历史上 Focus-Window 用 cx=0/cy=0 的 SetWindowPos 把它缩成了 +# 0x0,Windows Terminal 的 min-size 兜底把窗口变成 480x84 的一条,位置还 +# 被挪到了 (0,0)。只还原的话,出来的是那条,不是满屏。 +# +# 所以这里三步走:先还原到普通状态,再按 work area 显式设尺寸(这一步顺手 +# 把那个坏掉的"还原尺寸"也修回来),最后再最大化。 +function Show-WindowFullScreen([IntPtr]$h) { + try { + $wa = [WinAPI]::GetWorkAreaForWindow($h) + if ($wa.Left -eq -1) { return $null } + $cx = $wa.Right - $wa.Left + $cy = $wa.Bottom - $wa.Top + # 三步,顺序不能乱: + # 1) SW_RESTORE 先退出最大化 / 最小化,回到"普通"状态 + # 2) SetWindowPos 按 work area 显式设大小 —— 这一步发生在普通状态下, + # 所以窗口的"还原尺寸"被真正写成 work area + # 3) SW_MAXIMIZE 再最大化 + # + # 为什么第 2 步不能省:对**已经最大化**的窗口调 SetWindowPos,Windows + # 不会更新它的还原尺寸,那个坏尺寸(历史上被 0x0 缩成 480x84 的)就 + # 一直留着 —— 以后任何一次还原(用户点还原按钮、WT 自己恢复)都会把 + # 那条小条请回来。 + # + # 为什么不用 SetWindowPlacement:实测对 WT 它不生效,rcNormalPosition + # 写进去,读出来还是 0x0。 + [WinAPI]::ShowWindow($h, 9) | Out-Null # SW_RESTORE + [WinAPI]::SetWindowPos($h, [IntPtr]::Zero, $wa.Left, $wa.Top, $cx, $cy, [WinAPI]::SWP_NOZORDER) | Out-Null + [WinAPI]::ShowWindow($h, 3) | Out-Null # SW_MAXIMIZE + return @{ Left = $wa.Left; Top = $wa.Top; W = $cx; H = $cy } + } catch { + return $null + } +} + # Raise a window to the front: show + focus, whatever its current visible # state is. After the desktop-app adaptation both focus and toggle take a # resolved target object; the caller decides which one it is. @@ -1431,15 +1476,30 @@ function Focus-Window { try { [WinAPI]::AllowSetForegroundWindow([uint32]$Target.Pid) | Out-Null + # 只在窗口真的"看不见"时才动它的显示状态: + # minimized -> SW_RESTORE(还原) + # hidden -> SW_SHOW(显示) + # 已经可见的窗口一个字节都不碰 —— 对一个已经最大化的窗口再调一次 + # SW_SHOW 没有意义,只是白白多一次状态抖动。 if ([WinAPI]::IsIconic($Target.Hwnd)) { [WinAPI]::ShowWindow($Target.Hwnd, 9) | Out-Null # SW_RESTORE - } else { + } elseif (-not [WinAPI]::IsWindowVisible($Target.Hwnd)) { [WinAPI]::ShowWindow($Target.Hwnd, 5) | Out-Null # SW_SHOW } # TOPMOST -> NOTOPMOST: it lets BringWindowToTop push the window all the # way up without leaving a "always on top" side effect behind. - [WinAPI]::SetWindowPos($Target.Hwnd, [WinAPI]::HWND_TOPMOST, 0, 0, 0, 0, [WinAPI]::SWP_NOACTIVATE) | Out-Null - [WinAPI]::SetWindowPos($Target.Hwnd, [IntPtr]::new(-2), 0, 0, 0, 0, [WinAPI]::SWP_NOACTIVATE) | Out-Null # HWND_NOTOPMOST + # + # SWP_NOSIZE / SWP_NOMOVE 是必须的,不是锦上添花:这里 cx=0/cy=0/ + # X=0/Y=0 的意思是"大小和位置都不变",但这个意思**只在 flag 齐全时 + # 才成立**。少了 SWP_NOSIZE,Windows 会把它当成"resize 到 0x0"—— + # Windows Terminal 有 min-size 兜底,于是窗口塌成 480x84 的一条; + # 少了 SWP_NOMOVE 同理,窗口会被真的挪到 (0,0) 左上角。 + # + # 同一文件里 Toggle-Window 的注释早就写明了这个坑,但 Focus-Window + # 一直漏着,直到会话点击真的走到这里才暴露出来。 + $nofollow = [WinAPI]::SWP_NOACTIVATE -bor [WinAPI]::SWP_NOSIZE -bor [WinAPI]::SWP_NOMOVE + [WinAPI]::SetWindowPos($Target.Hwnd, [WinAPI]::HWND_TOPMOST, 0, 0, 0, 0, $nofollow) | Out-Null + [WinAPI]::SetWindowPos($Target.Hwnd, [IntPtr]::new(-2), 0, 0, 0, 0, $nofollow) | Out-Null # HWND_NOTOPMOST [WinAPI]::BringWindowToTop($Target.Hwnd) | Out-Null [WinAPI]::SetForegroundWindow($Target.Hwnd) | Out-Null # "DONE" 而不是 "OK":这里只说明这几个调用没抛异常,不代表窗口真到了 @@ -1481,18 +1541,15 @@ function Toggle-Window { [WinAPI]::ShowWindow($Target.Hwnd, 0) | Out-Null # SW_HIDE Dbg "TOGGLE: hid target=$($Target.Exe) PID=$($Target.Pid) hwnd=$($Target.Hwnd)" } else { - # 1) SW_MAXIMIZE 激活+标记 maximized - [WinAPI]::ShowWindow($Target.Hwnd, 3) | Out-Null # SW_MAXIMIZE - # 2) SetWindowPos 强制拉到 monitor work area (绕过 DPI/remembered-size 限制) - $wa = [WinAPI]::GetWorkAreaForWindow($Target.Hwnd) - if ($wa.Left -ne -1) { - $cx = $wa.Right - $wa.Left - $cy = $wa.Bottom - $wa.Top - [WinAPI]::SetWindowPos($Target.Hwnd, [IntPtr]::Zero, $wa.Left, $wa.Top, $cx, $cy, [WinAPI]::SWP_NOZORDER) | Out-Null + # 1)+2) SW_MAXIMIZE 激活+标记 maximized,再用 SetWindowPos 强制拉到 + # monitor work area(绕过 DPI/remembered-size 限制;细节见 + # Show-WindowFullScreen 的注释) + $wa = Show-WindowFullScreen $Target.Hwnd + if ($wa) { # The -f has to sit inside the parentheses: written as # `Dbg "..." -f a, b` the format operator binds to the command, not to # the string, and the log line keeps the raw {0} {1} placeholders. - Dbg ("TOGGLE: forced to work area ({0},{1}) {2}x{3}" -f $wa.Left, $wa.Top, $cx, $cy) + Dbg ("TOGGLE: forced to work area ({0},{1}) {2}x{3}" -f $wa.Left, $wa.Top, $wa.W, $wa.H) } # 3) 抢 z-order 到最前(SetWindowPos(HWND_TOP) 不需要 foreground 权限) # SWP_NOSIZE:cx=0/cy=0 缺它会被 Windows 当成"resize 到 0x0", diff --git a/plugins/antianqi/mcode-island/scripts/smoke.mjs b/plugins/antianqi/mcode-island/scripts/smoke.mjs index a86c0778..9bb70f5f 100644 --- a/plugins/antianqi/mcode-island/scripts/smoke.mjs +++ b/plugins/antianqi/mcode-island/scripts/smoke.mjs @@ -607,37 +607,72 @@ const main = async () => { out('WARN', 'mcode-island.ps1: Toggle-Window body not found (drift lock skipped)'); } else { const toggleBody = toggleMatch[0]; - // Extract the `else` branch (the restore path) so the check - // is anchored on the show branch, not the hide branch (which - // intentionally uses SW_HIDE=0). - const elseMatch = toggleBody.match(/else\s*\{([\s\S]*?)\n\s*\}\s*\n\s*\}\s*$/m); - const restoreBody = elseMatch ? elseMatch[1] : ''; - if (!restoreBody) { - out('FAIL', 'mcode-island.ps1: Toggle-Window else branch not parseable'); - } else if (!/ShowWindow\(\s*\$Target\.Hwnd\s*,\s*3\s*\)/.test(restoreBody)) { - out('FAIL', 'mcode-island.ps1: Toggle restore branch does not call SW_MAXIMIZE (ShowWindow(_, 3)). A regression to SW_SHOW (5) or SW_RESTORE (9) re-shows the window at its pre-hide size (e.g., 480x84 strip if WT got accidentally resized).'); - } else if (/ShowWindow\(\s*\$Target\.Hwnd\s*,\s*5\s*\)/.test(restoreBody)) { - out('FAIL', 'mcode-island.ps1: Toggle restore branch calls SW_SHOW (5) in addition to SW_MAXIMIZE — keep only SW_MAXIMIZE; SW_SHOW re-shows at pre-hide size and defeats the maximize intent.'); + // Round-15 (rewritten): "show the window full-screen" now lives + // in ONE helper, and every caller must use it. + // + // SW_MAXIMIZE (3), not SW_SHOW (5) / SW_RESTORE (9): SW_HIDE + // preserves the window's "non-maximized size", and if that + // size got clobbered -- a SetWindowPos without SWP_NOSIZE + // resizes WT to 0x0 and WT clamps it to a ~480x84 strip -- + // then SW_SHOW / SW_RESTORE re-shows the strip instead of the + // window. That was the user's "show is a thin strip" bug. + // + // The session-list click needs the same guarantee (it raises + // the terminal without toggling it), so the helper is shared + // and the lock has to check the helper AND both callers + // instead of the text inside Toggle-Window alone. + const fullScreenMatch = widget.match(/function Show-WindowFullScreen[\s\S]*?\n\}\n/); + if (!fullScreenMatch) { + out('FAIL', 'mcode-island.ps1: Show-WindowFullScreen helper not found -- the restore path and the session click both need one implementation that maximizes to the work area'); } else { - out('PASS', 'mcode-island.ps1: Toggle restore branch forces SW_MAXIMIZE (full-screen on show, fixes 480x84 strip bug)'); + const fsBody = fullScreenMatch[0]; + const maximizeAt = fsBody.search(/ShowWindow\(\s*\$h\s*,\s*3\s*\)/); + const restoreAt = fsBody.search(/ShowWindow\(\s*\$h\s*,\s*9\s*\)/); + const showAt = fsBody.search(/ShowWindow\(\s*\$h\s*,\s*5\s*\)/); + if (maximizeAt < 0) { + out('FAIL', 'mcode-island.ps1: Show-WindowFullScreen does not call SW_MAXIMIZE (ShowWindow(_, 3)). A regression to SW_SHOW (5) or SW_RESTORE (9) re-shows the window at its pre-hide size (e.g., 480x84 strip if WT got accidentally resized).'); + } else if (showAt >= 0) { + out('FAIL', 'mcode-island.ps1: Show-WindowFullScreen calls SW_SHOW (5) -- SW_SHOW re-shows the window at its pre-hide size and defeats the maximize intent.'); + } else if (restoreAt < 0) { + // SW_RESTORE has to come FIRST: the SetWindowPos that + // writes the restored size only takes effect while the + // window is in its normal (not maximized) state. + out('FAIL', 'mcode-island.ps1: Show-WindowFullScreen does not call SW_RESTORE (9) before the maximize -- without it the SetWindowPos cannot write the restored size, so a stale size (e.g. a 480x84 strip) survives the next restore.'); + } else if (restoreAt > maximizeAt) { + out('FAIL', 'mcode-island.ps1: Show-WindowFullScreen calls SW_RESTORE (9) AFTER SW_MAXIMIZE (3) -- restoring after the maximize puts the window back at its pre-hide size (the 480x84 strip bug).'); + } else if (!/Show-WindowFullScreen/.test(toggleBody)) { + out('FAIL', 'mcode-island.ps1: Toggle-Window does not call Show-WindowFullScreen -- the show path would skip the maximize and go back to the pre-hide size.'); + } else { + const focusTermMatch = widget.match(/function Focus-TerminalWindow[\s\S]*?\n\}\n/); + const focusTermBody = focusTermMatch ? focusTermMatch[0] : ''; + if (!focusTermBody) { + out('FAIL', 'mcode-island.ps1: Focus-TerminalWindow body not found -- the session click must raise the terminal full-screen too'); + } else if (!/Show-WindowFullScreen/.test(focusTermBody)) { + out('FAIL', 'mcode-island.ps1: Focus-TerminalWindow does not call Show-WindowFullScreen -- raising the terminal from the session list would restore whatever "normal size" the window last had (e.g. a 480x84 strip in the top-left corner)'); + } else { + out('PASS', 'mcode-island.ps1: the restore path and the session click both force SW_MAXIMIZE via Show-WindowFullScreen (full-screen on show, fixes the 480x84 strip)'); + } + } } - // Round-16: Toggle's restore branch must also force the window - // to fill the actual monitor work area (MonitorFromWindow + - // GetMonitorInfo + SetWindowPos). SW_MAXIMIZE alone is - // insufficient on multi-monitor + DPI-virtualized setups: the - // user's primary monitor is physically 2560x1440, but WinForms - // [Screen]::PrimaryScreen reports 1920x1080 (DPI virtualization). - // SW_MAXIMIZE follows the 1920x1080 number and leaves WT at - // ~75% of the physical screen — visually "in the top-left corner" - // of the user's 2K monitor. The drift lock forces the explicit - // SetWindowPos path. - if (!/GetWorkAreaForWindow|GetMonitorInfo|MonitorFromWindow/.test(toggleBody)) { - out('FAIL', 'mcode-island.ps1: Toggle restore branch does not query monitor work area. Without MonitorFromWindow + SetWindowPos(explicit size), SW_MAXIMIZE alone fills only the WinForms 1920x1080 logical work area, not the actual 2560x1440 physical monitor — leaves WT at the top-left 75%.'); - } else if (!/SetWindowPos\([^)]*\$wa\.|SetWindowPos\(\$Target\.Hwnd,[^,]+,\s*\$wa\.Left,\s*\$wa\.Top,\s*\$cx,\s*\$cy/.test(toggleBody)) { - out('FAIL', 'mcode-island.ps1: Toggle restore branch has monitor query but does not SetWindowPos with work-area coords. The contract is: read monitor work area, then SetWindowPos with explicit (Left, Top, cx, cy) — never rely on SW_MAXIMIZE alone for size.'); + // Round-16 (rewritten): the work-area size lives in the same + // helper now, so the lock follows it there. + // + // SW_MAXIMIZE alone is insufficient on multi-monitor + + // DPI-virtualized setups: the user's primary monitor is + // physically 2560x1440, but WinForms [Screen]::PrimaryScreen + // reports 1920x1080 (DPI virtualization). SW_MAXIMIZE follows + // the 1920x1080 number and leaves WT at ~75% of the physical + // screen -- visually "in the top-left corner" of the user's 2K + // monitor. The drift lock forces the explicit SetWindowPos + // path. + const fullScreenBody = fullScreenMatch ? fullScreenMatch[0] : ''; + if (!/GetWorkAreaForWindow|GetMonitorInfo|MonitorFromWindow/.test(fullScreenBody)) { + out('FAIL', 'mcode-island.ps1: Show-WindowFullScreen does not query the monitor work area. Without MonitorFromWindow + SetWindowPos(explicit size), SW_MAXIMIZE alone fills only the WinForms 1920x1080 logical work area, not the actual 2560x1440 physical monitor -- leaves WT at the top-left 75%.'); + } else if (!/SetWindowPos\([^)]*\$wa\.|SetWindowPos\(\$h,[^,]+,\s*\$wa\.Left,\s*\$wa\.Top,\s*\$cx,\s*\$cy/.test(fullScreenBody)) { + out('FAIL', 'mcode-island.ps1: Show-WindowFullScreen has the monitor query but does not SetWindowPos with work-area coords. The contract is: read monitor work area, then SetWindowPos with explicit (Left, Top, cx, cy) -- never rely on SW_MAXIMIZE alone for size.'); } else { - out('PASS', 'mcode-island.ps1: Toggle restore branch forces work-area size via MonitorFromWindow + SetWindowPos (fills 2560x1440 physical monitor, not just 1920x1080 logical)'); + out('PASS', 'mcode-island.ps1: the full-screen path forces work-area size via MonitorFromWindow + SetWindowPos (fills 2560x1440 physical monitor, not just 1920x1080 logical)'); } // Round-17 + round-19: the follow-up z-order SetWindowPos call @@ -1729,6 +1764,93 @@ const main = async () => { out('PASS', `ps1 reserved-variable guard: no assignment to a read-only PowerShell automatic variable (${ps1Files.length} scanned)`); } + // --- SetWindowPos must not resize or move the target ----------------- + // + // SetWindowPos(hwnd, after, X, Y, cx, cy, flags): cx=0/cy=0 and + // X=0/Y=0 only mean "keep the current size / position" when the + // matching flag is present. + // + // missing SWP_NOSIZE -> the window is resized to 0x0. Windows + // Terminal has a min-size fallback, so the window collapses into a + // 480x84 strip instead of disappearing. + // missing SWP_NOMOVE -> the window is really moved to (0,0). + // + // That is not a hypothetical: Focus-Window did exactly this, so every + // "raise the terminal" collapsed it into a strip in the top-left + // corner of the screen. The flags were also written through a local + // variable, so the check has to resolve the variable instead of + // looking at the call site only. + const splitCallArgs = (body) => { + const args = []; + let cur = ''; + let d = 0; + for (const ch of body) { + if (ch === '(') { d++; cur += ch; continue; } + if (ch === ')') { d--; cur += ch; continue; } + if (ch === ',' && d === 0) { args.push(cur.trim()); cur = ''; continue; } + cur += ch; + } + args.push(cur.trim()); + return args; + }; + + const findSetWindowPosCalls = (code) => { + const calls = []; + const re = /\bSetWindowPos\s*\(/g; + let m; + while ((m = re.exec(code)) !== null) { + let depth = 1; + let i = m.index + m[0].length; + while (i < code.length && depth > 0) { + if (code[i] === '(') depth++; + else if (code[i] === ')') depth--; + i++; + } + const body = code.slice(m.index + m[0].length, i - 1); + calls.push({ + line: code.slice(0, m.index).split('\n').length, + args: splitCallArgs(body), + }); + } + return calls; + }; + + // The flags argument is often a local variable ($nofollow / $flags); + // resolve it so the guard sees the flags that are actually passed. + const resolveFlags = (code, flagsArg) => { + if (!flagsArg) return ''; + if (!flagsArg.startsWith('$')) return flagsArg; + const name = flagsArg.slice(1).split(/[^\w]/)[0]; + const m = code.match(new RegExp('\\$' + name + '\\s*=\\s*([^\\r\\n]+)', 'i')); + return m ? m[1] : ''; + }; + + let swpHits = 0; + for (const abs of ps1Files) { + const rel = abs.slice(PLUGIN_ROOT.length + 1); + const code = stripLiterals(await readFile(abs, 'utf8')).join('\n'); + for (const call of findSetWindowPosCalls(code)) { + const a = call.args; + if (a.length < 7) continue; // not a real call site + const flags = resolveFlags(code, a[6]); + const x = a[2].trim(); + const y = a[3].trim(); + const cx = a[4].trim(); + const cy = a[5].trim(); + if (!/\bSWP_NOSIZE\b/.test(flags) && cx === '0' && cy === '0') { + out('FAIL', `${rel}:${call.line}: SetWindowPos passes cx=0/cy=0 without SWP_NOSIZE -- that resizes the window to 0x0 (Windows Terminal clamps it to a ~480x84 strip)`); + swpHits++; + } + if (!/\bSWP_NOMOVE\b/.test(flags) && x === '0' && y === '0') { + out('FAIL', `${rel}:${call.line}: SetWindowPos passes X=0/Y=0 without SWP_NOMOVE -- that moves the window to the top-left corner`); + swpHits++; + } + } + } + if (swpHits === 0) { + out('PASS', `ps1 SetWindowPos guard: no call resizes or moves its target without SWP_NOSIZE / SWP_NOMOVE (${ps1Files.length} scanned)`); + } + finish(); };