diff --git a/plugins/antianqi/mcode-island/mcode-island.ps1 b/plugins/antianqi/mcode-island/mcode-island.ps1 index 6c05cab0..072c3c26 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); @@ -211,7 +212,14 @@ $xaml = @' Topmost="True" ShowInTaskbar="False" ResizeMode="NoResize" WindowStartupLocation="Manual" UseLayoutRounding="True" SnapsToDevicePixels="True" Focusable="False" ShowActivated="False"> - + + + + + + + @@ -297,6 +305,23 @@ $xaml = @' VerticalAlignment="Center"/> + + + + + + '@ @@ -327,6 +352,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 +369,585 @@ $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() +} + +# 列表里每一项的高度(含内边距和分隔线)。 +# +# 两行(标题 + 摘要)加上下各 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 } + # 状态 -> 文案 + 颜色。 + # + # 用的是 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)就退到"正在 + # 调用的工具名",总比空着强。 + $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.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 + + # 两行:标题 / 摘要。 + $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 = 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) + + $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.5 + $txt.TextTrimming = [System.Windows.TextTrimming]::CharacterEllipsis + $txt.VerticalAlignment = [System.Windows.VerticalAlignment]::Center + + $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($dotHex))) + $tag.FontSize = 10 + $tag.HorizontalAlignment = [System.Windows.HorizontalAlignment]::Right + + $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), + # 列表在同一个窗口里,不拦住的话点一行会被当成"开始拖药丸"。 + $border.Add_MouseLeftButtonDown({ + $args[1].Handled = $true + Invoke-SessionClick $id + }.GetNewClosure()) + + $script:sessionRows.Children.Add($border) | 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 + # 运行中的排前面,其余按"最后一次动静"倒序。 + # + # 排序用 lastAtMs(最后活动时间),不是 sinceMs(进列表的时间): + # 进列表的时间只说明"我什么时候看见它",说明不了"它现在有多活跃"。 + # + # 顺序:在跑的排最前,然后是等你的、再是等完了的,停了的排最后。 + $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)) + $i++ + } + # 列表高度不能按"行数 × 猜的行高"算。 + # + # 那样算有两处错:摘要会换行,一项不止那么高;而且 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 = [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) +} + +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 + # 先试"切到已经开着这个会话的终端 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 + " foregroundPid=" + (Get-ForegroundPid)) + return + } + # 没找到具体 tab:把终端窗口带到前台,但不要再开一个终端。 + # + # 会话名对不上只该影响"切到哪个标签",不该造成"同一个会话两个终端"。 + # 只有连一个终端窗口都找不到时(桌面端 app 内嵌的终端就是这种), + # 才退回新开 —— 那时候新开是唯一到得了那个会话的办法。 + if (Activate-TerminalWindow) { + 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: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 的"显示/隐藏终端"早就解决过这个问题,用的是既有那套聚焦:先 +# TOPMOST 再 NOTOPMOST(这样 BringWindowToTop 才推得动),然后 +# SetForegroundWindow。直接复用,不另造一套。 +# +# 裸的 SetForegroundWindow 不够:调用方是后台进程时,Windows 的前台锁会 +# 挡下来 —— 窗口切了 tab,用户却看不到变化。 +function Focus-TerminalWindow([IntPtr]$h) { + if (-not $h -or $h -eq [IntPtr]::Zero) { return $false } + try { + # 变量名不能叫 $pid —— $PID 是只读自动变量,赋值会抛异常,被这里的 + # 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) + return $true + } + # 第一套没抢到:换主 pill 真正在用的那套。 + # + # Focus-Window 里两次 SetWindowPos 都带 SWP_NOACTIVATE,只动 z-order + # 不激活;主 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 + [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 + } +} + +# 切到已经开着这个会话的终端 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 不会把窗口带到前台,而裸的 SetForegroundWindow 又会被 + # Windows 的前台锁挡下(调用方是后台进程)—— 实测窗口切了 tab, + # 用户却看不到任何变化。 + # + # 所以复用主 pill 的聚焦:先 TOPMOST 再 NOTOPMOST,让 + # BringWindowToTop 能把窗口推到最前,最后 SetForegroundWindow。 + # tab 已经切过去了。即使抢前台没成功,也**不能**回退去新开一个终端 + # —— 那正是"同一个会话两个终端"的病根。抢前台失败只记日志。 + if (-not (Focus-TerminalWindow ([IntPtr]$w.Current.NativeWindowHandle))) { + Dbg ("session tab selected but foreground not confirmed: " + ($names -join ' | ')) + } + 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 { + # 找到终端窗口就算"找到了"。抢前台没成功**不能**返回 $false —— + # 调用方拿 $false 会新开一个终端,同一个会话就多出一个终端了。 + Focus-TerminalWindow ([IntPtr]$w.Current.NativeWindowHandle) | Out-Null + return $true + } catch {} + } + } catch { + } + return $false +} + +# 切到这个 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 色。 @@ -821,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. @@ -829,18 +1476,35 @@ 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 - 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)" } @@ -877,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", @@ -1039,6 +1700,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 +1775,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 +1823,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 +1930,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 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/Runtime-Titles.ps1 b/plugins/antianqi/mcode-island/scripts/lib/Runtime-Titles.ps1 new file mode 100644 index 00000000..886ae184 --- /dev/null +++ b/plugins/antianqi/mcode-island/scripts/lib/Runtime-Titles.ps1 @@ -0,0 +1,195 @@ +# --------------------------------------------------------------------------- +# 会话标题(运行时 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 + +# 用户主目录。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。 +# +# 顺序有讲究: +# 1. PATH 上的 node —— 开发者机器上最常见的 +# 2. CLI 自带的 node —— ~/.minimax-code/runtime/node-*/node.exe +# 3. 桌面端 app 当 node 用 —— ELECTRON_RUN_AS_NODE=1 +# +# 第 3 条是给上架版用的:桌面端用户不一定装了 node,但一定跑着 app。 +# +# **这个函数不许抛异常**:没有 node 就是没有标题,不是错误。 +function Get-NodeCommand { + 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 + $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 + 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' } } + } + } catch { + # 找不到就是找不到。 + } + return $null +} + +# 用 node 读一次标题表。 +# +# 返回 @{ id -> title },读不到就返回空表。**任何失败都只是少一个标题**, +# 不该把 detector 带崩:所以这里只吞异常,不往上抛。 +# +# 整个函数体都在 try 里,包括路径拼接 —— CI 的 ubuntu job 没有 +# USERPROFILE,Join-Path $null 会抛,那次失败就是这么来的。 +function Read-RuntimeTitles { + $result = @{} + $tmp = $null + try { + # MCODE_RUNTIME_STATE_DB 可以指向别的库。测试要用一个自己建的临时库, + # 不然就只能读开发机上那个 1.9GB 的真库。 + $db = $env:MCODE_RUNTIME_STATE_DB + if (-not $db) { + $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 } + + $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)); +'@ + + # 临时目录走 .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 + $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 ($tmp -and (Test-Path -LiteralPath $tmp)) { + try { [System.IO.File]::Delete($tmp) } catch {} + } + } + return $result +} + +# 取某个 session 的标题。 +# +# 有缓存:标题很少变,60s 内直接用旧的。 +# +# 和读表一样,这里也不许抛:这个函数在 detector 的主循环里被调用, +# 一次异常就是整个列表停摆。 +function Get-RuntimeSessionTitle([string]$id) { + 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 { + # 没有标题就是没有标题。 + } + return '' +} 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..10ef5596 --- /dev/null +++ b/plugins/antianqi/mcode-island/scripts/lib/Session-Tracker.ps1 @@ -0,0 +1,671 @@ +# --------------------------------------------------------------------------- +# 多 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 + +# 会话标题(运行时的 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 才算"有活动"。 +# +# 这个窗口只决定"能不能进列表",不决定进列表之后显示什么: +# 进列表之后状态只看最后一条消息。为什么分开: +# +# - "有活动"必须看 mtime。一个几周前卡在 toolCall 上的老对话,最后 +# 一条仍然是 toolCall,消息类型说它"在跑",它其实早就不动了。只看 +# 消息类型会把全部历史会话一次性收进列表(实测 80+ 条)。 +# - "在跑还是在等你"必须看消息。agent 跑一个 40 秒的工具时日志可能 +# 40 秒没写,但它没停;用 mtime 判定状态会把"正在跑长工具"误判成 +# "待回复",而且窗口多长都不对。 +# +# 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_') + 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 消息。所以要往下扫。 +# +# 判断一段正文是不是系统注入的消息,而不是用户或 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) { + 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 } + # 系统注入的 user 消息不是用户说的话,跳过它继续找。 + if (Test-IsSystemInjected $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 现在是什么状态。 +# +# **和 detector 的 Infer-State 同源**:同一套状态词表、同一套判定规则。 +# +# 之前这里自己写了一套 running/waiting/stopped,两边越走越远:"用户刚 +# 说完"在 pill 上是 idle(等用户),在列表里却是 running;pill 有的 +# "已完成"(工具跑完)列表里根本没有。同一个会话在两个地方显示成两回事。 +# +# 所以这里直接产出 pill 那套状态: +# +# working agent 在干活(跑工具,或者刚跑完一个工具、还要接着干) +# thinking agent 在想 +# waiting agent 在等审批(ask_user)—— 球在用户那边 +# idle agent 说完了,或者用户刚说完 —— 球在用户那边 +# +# 注意这里**没有** done 和 error:pill 上的"已完成""出错"是"刚刚跑完 +# 一个工具""这个工具炸了"的瞬时反馈,后面还跟着具体动词;放进列表会被 +# 读成"这个会话做完了",而工具跑完之后 agent 还在这一轮里。列表只说 +# 会话级的状态。 +# +# 只产出状态,不产出 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 + # 一个工具跑完了,**这一轮还没结束**: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' + } + if ($kind -eq $S_ASSISTANT -and [string]$msg.phase -eq $S_THINKING) { return 'thinking' } + return '' + } + $role = [string]$m.role + # 用户刚说完:球在用户那边,agent 马上要动。pill 也判 idle("等用户")。 + if ($role -eq $S_USER) { return 'idle' } + if ($role -eq $S_ASSISTANT) { + # 和 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 } + } + } + 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' + } + # 同上:工具跑完了(成不成都一样),这一轮还在继续。 + # + # 成功和失败都不改状态 —— 工具级的成败是 pill 的事(它绿点/红点一闪, + # 后面还跟着"Read""Edited"这类动词,是瞬时反馈)。会话级的状态是 + # "这一轮还在跑",工具失败也一样:agent 会重试、或者换个办法。 + if ($role -eq $S_TOOLRESULT) { return 'working' } + 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-SessionState 也已经解析过 $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 -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 '' } + 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 在列表里显示的名字。 +# +# 优先用运行时的会话标题(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)算新的一轮,那时它可以重新进来。 +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) { + # 日志没变:消息没变,状态也不该变。 + # + # 但"运行中"需要一份活性支撑。agent 跑完一个工具之后,它的最后 + # 一条消息永远是 toolCall 或 toolResult —— 消息类型说它"在跑", + # 其实它可能早就停了。而这一行原本直接 continue,状态会冻结在 + # 最后一个值:一个停了快一小时的会话会一直显示"运行中"。 + # + # 所以在这里降级一次:超过窗口没动就不是"在跑",是"停了"。 + if ($entry.Phase -in $script:ACTIVE_STATES) { + $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 } + $phase = Get-SessionState $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 -in $script:ROUND_END_STATES) -and ($phase -in $script:ACTIVE_STATES)) { + $cycle = $d.Cycle + 1 + $script:dismissedSessions.Remove($id) + $script:sessionsDirty = $true + } else { + # 还没到新一轮:只更新它现在的状态,等它"待回复 -> 运行中"的那 + # 一次转换时再放回来。 + $d.Phase = $phase + continue + } + } + $script:trackedSessions[$id] = @{ + Id = $id + Title = (Get-SessionDisplayTitle $id $g.Log) + Phase = $phase + 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) + # 见过的所有标题。终端 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 + } + $script:sessionsDirty = $true + Log-Line "session tracked: $id ($phase)" + continue + } + + # 已跟踪的:状态变了就更新 + # + # 一轮的终点(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)) { + $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 + # 第二行和右侧时间戳跟着最后一条消息走。 + # + # 变了就置 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 + # 标题变了就记进历史(最多留 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 + # 缓存,所以每秒问一次并不会每秒起进程。 + 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) } + } +} + +# 写 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 + # 列表第二行 / 右侧时间戳。缺字段的旧条目补空串,不要 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 }) + # 见过的所有标题,widget 拿它去匹配终端 tab。 + titles = @($e.Titles | Where-Object { $_ }) + } + } + $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 + # 第二行和右侧时间戳也要恢复: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 }) + Titles = @($s.titles | Where-Object { $_ }) + 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 = 'working' } + } + $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-SessionState $msg + + # 两种补录: + # - 刚动过(60 秒内):它此刻真的在跑 + # - 说完在等你(30 分钟内,且最后一条是 agent 收尾):detector + # 重启前它刚说完,那次"从运行中变成待回复"的转换我们没看见, + # 不补录的话这个"待回复"就永远等不到了 + $isActive = ($g.MTime -ge $activeCut) + $isFinishedWaiting = ($g.MTime -ge $seedCut) -and ($phase -in $script:ROUND_END_STATES) + 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/smoke.mjs b/plugins/antianqi/mcode-island/scripts/smoke.mjs index 2b3eb669..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 @@ -1592,6 +1627,230 @@ 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)`); + } + + // --- 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(); }; 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..e5f4f8ab --- /dev/null +++ b/plugins/antianqi/mcode-island/scripts/test-session-tracker.ps1 @@ -0,0 +1,532 @@ +# 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' +# 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' } + +# 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}}' +} +# 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 { + 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}}' +} +# 系统注入的消息:后台任务完成通知、系统提醒这些,是以 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}}' +} +# 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')) +$fDone = New-Session 'sess-waiting' @((Line-Assistant '我先去做点别的')) +$fAsk = New-Session 'sess-asking' @((Line-ToolCall 'ask_user')) +$fUser = New-Session 'sess-userjust' @((Line-User '帮我看下这个')) + +# 这些状态词表和 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)) + +# 工具跑完 / 炸了 / 只想不动手。 +# +# 工具跑完(成不成都一样)仍然是"运行中":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 '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. 标题提取 --------------------------------------------------------- +$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 '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))) +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) + +# --- 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) + +# 系统注入的消息不能当摘要。 +# +# 系统把后台任务完成通知这些以 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 吞掉之后,运行时标题就读不出来了 —— 列表里显示成首条消息的 +# 摘要,而不是会话名(用户报的就是这个)。 +# +# 这条断言照着这个坑写的:源码里不许再出现 $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 在**某个时刻**设的,而会话标题会随着对话 +# 推进变。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,摘要和最后活动时间却在变。 +# 只看 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 + "'") + +# --- 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: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 = @{} +} + +# --- 10. 停了的会话不该一直显示"运行中" ----------------------------------- +# +# agent 跑完一个工具之后,它的最后一条消息永远是 toolCall 或 toolResult +# —— 消息类型说它"在跑"。而日志不再变之后,更新循环第一行就跳过了, +# 状态冻结在最后一个值:一个停了快一小时的会话会一直显示"运行中"。 +# +# 所以"在跑"必须有一份活性支撑:超过窗口没动,就不是"在跑",是"停了"。 +$fStale = New-Session 'sess-stale' @((Line-ToolCall 'Bash')) +Update-SessionTracker +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 'working') ("得到: " + $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 '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。 +$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 } +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 ecb6ab8e..a2537436 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`); + } +} + // --------------------------------------------------------------------------- // 11. The 5h usage survives a tick (the blank-badge regression) // --------------------------------------------------------------------------- @@ -1413,4 +1463,4 @@ if (fail > 0) { console.log('\nfailures:'); for (const f of failures) console.log(` - ${f}`); process.exit(1); -} +} \ No newline at end of file