diff --git a/.github/workflows/mcode-island-windows.yml b/.github/workflows/mcode-island-windows.yml index 2445f47d..2046c281 100644 --- a/.github/workflows/mcode-island-windows.yml +++ b/.github/workflows/mcode-island-windows.yml @@ -411,3 +411,56 @@ jobs: } Write-Host "test c (no token): OK returned null" Write-Host "Get-5hUsage via dot-source + matching fixture + token-source precedence: 3/3 OK" + + # 5) Behavioral sub-step + redaction suite (round-19 review #5). + # + # scripts/test-substep-progress.mjs existed but no workflow ran it, so + # its 20 assertions never gated anything. It is the only suite that + # executes the PowerShell under test rather than grepping for + # substrings, and it carries the secret canary for review item #1, so + # leaving it unwired meant a redaction regression could land silently. + # + # The suite resolves its own PowerShell: it uses $env:PS_BIN if set, + # otherwise the value if it is an existing path, otherwise it probes + # pwsh / powershell.exe / powershell on PATH and SKIPS the behavioral + # checks loudly if none is found. It never assumes a + # user-specific absolute path. + # + # Negative-injection: restore the `$detail = "$act '$txt'"` line in + # _lib.ps1, or drop SWP_NOMOVE from Toggle-CallerWindow, and this step + # fails on the canary / the flag assertion respectively. + - name: Behavioral sub-step + redaction suite (round-19 requirement #5) + run: | + [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + $OutputEncoding = [System.Text.Encoding]::UTF8 + try { chcp 65001 | Out-Null } catch {} + + # Report which PowerShell the suite will use. Not a hard requirement: + # the suite does its own resolution and falls back to PATH. + $candidates = @('pwsh', 'powershell.exe', 'powershell') + $chosen = $null + foreach ($c in $candidates) { + if (Get-Command $c -ErrorAction SilentlyContinue) { $chosen = $c; break } + } + if ($chosen) { + Write-Host "PowerShell on PATH: $chosen ($((& $chosen -NoProfile -Command '$PSVersionTable.PSVersion.ToString()')))" + } else { + Write-Host "No PowerShell found on PATH; the suite will fail loudly rather than skip" + } + + # Resolve the suite path against the repo root instead of relying on + # the working directory. GitHub Actions runs every `run:` block from + # $GITHUB_WORKSPACE, not from the plugin directory, so a bare + # `scripts/...` path resolves to the wrong place and node exits + # MODULE_NOT_FOUND. It passed locally only because the local shell + # happened to be sitting in the plugin directory. + $suite = 'plugins/antianqi/mcode-island/scripts/test-substep-progress.mjs' + if (-not (Test-Path $suite)) { + throw "$suite not found (cwd: $((Get-Location).Path))" + } + Write-Host "Suite: $((Resolve-Path $suite).Path)" + + node $suite + if ($LASTEXITCODE -ne 0) { + throw "test-substep-progress.mjs failed with exit code $LASTEXITCODE" + } diff --git a/plugins/antianqi/mcode-island/.gitattributes b/plugins/antianqi/mcode-island/.gitattributes index 81407448..ef9c5280 100644 --- a/plugins/antianqi/mcode-island/.gitattributes +++ b/plugins/antianqi/mcode-island/.gitattributes @@ -1,7 +1,25 @@ -# Force LF for all source files in this plugin. PowerShell 5.1 reads -# CRLF fine, but a cross-platform smoke (e.g. Linux CI) sees LF and -# the pre-existing CRLF-handling bug in scripts/validate.mjs trips -# on Windows-checked-out CRLF. LF avoids both failure modes. +# Force LF for all source files in this plugin, so a cross-platform +# smoke (e.g. Linux CI) does not trip the pre-existing CRLF-handling bug +# in scripts/validate.mjs on a Windows-checked-out tree. +# +# IMPORTANT: this policy applies to .mjs / .json / .md, and it is safe +# for .ps1 ONLY because every .ps1 that contains non-ASCII also carries +# a UTF-8 BOM (enforced by smoke check 11). +# +# Round-20 correction: the comment here used to say "PowerShell 5.1 reads +# CRLF fine, so LF avoids both failure modes". That is backwards, and it +# is what hid a real outage. Windows PowerShell 5.1 decodes a BOM-less +# script using the system ANSI codepage, so an LF .ps1 containing +# non-ASCII comments is mis-decoded and the C# here-string in +# mcode-status-detect.ps1 stops being a here-string -- `using System;` +# gets parsed as PowerShell and the detector dies before its first +# statement. Verified against 5.1: both CRLF and a UTF-8 BOM fix the +# parse, bare LF does not. The BOM is used rather than flipping this file +# to CRLF precisely so the Linux-side validator stays protected. +# +# Do NOT "clean up" the BOMs. If you rewrite a .ps1 with an editor that +# drops them, smoke check 11 will fail and any `Copy-Item` sync into the +# install directory will install a dead detector. # # Override at clone time: `git config core.autocrlf input` for a # one-shot pull, or set `[core] autocrlf = false` globally. diff --git a/plugins/antianqi/mcode-island/README.md b/plugins/antianqi/mcode-island/README.md index 0f39645d..721f914c 100644 --- a/plugins/antianqi/mcode-island/README.md +++ b/plugins/antianqi/mcode-island/README.md @@ -156,6 +156,28 @@ alternative: & "%PLUGIN_DIR%\mcode-island\notify-island.ps1" -State error -Message "npm test failed" ``` +For sub-step progress (Computer Use iterative loops, multi-step plans), +pass `-Step` / `-Total` / `-Detail` so the pill shows what the agent is +doing *right now*: + +```powershell +& "%PLUGIN_DIR%\mcode-island\notify-island.ps1" -State working -Message "Computer Use" ` + -Step 3 -Total 12 -Detail "fill username field" +# → pill renders: "step 3/12 · fill username field" + +& "%PLUGIN_DIR%\mcode-island\notify-island.ps1" -State working -Message "Bash" ` + -Step 5 -Detail "npm install" +# → pill renders: "step 5 · npm install" + +& "%PLUGIN_DIR%\mcode-island\notify-island.ps1" -State done -Message "Bash ok" +# → pill renders: "Bash ok" (no step → legacy behavior, backward compat) +``` + +All three params are optional and backward compatible. The detail field +replaces the message in the rendered pill when present (avoids stacking +"Bash ok · fill username"). See `skills/mcode-island/SKILL.md` for the +full semantics and the contract with the widget renderer. + ## Quick start 1. **Install** — copy this folder into your `~/.minimax/plugins/mcode-island/` @@ -232,7 +254,7 @@ binary, no symlink, no `node_modules`. | .NET WPF runtime | 4.x (ships with Windows 10/11) | | mcode | any version (Mode B works everywhere); 0.2.4+ activates Mode A | | execution policy | `Bypass` for this directory; not changed globally | -| network access | **optional** — see "Network access" below. The widget itself is offline. `mcode-status-detect.ps1` only contacts `https://api.minimax.io/v1/coding_plan/remains` when a token is configured (see "Accounts" + "Data use"). | +| network access | **optional** — see "Network access" below. The widget itself is offline. `mcode-status-detect.ps1` only contacts `https://api.minimaxi.com/v1/coding_plan/remains` when a token is configured (see "Accounts" + "Data use"). | | accounts | **optional** — see "Accounts" below. No account is required to run the widget; a token is only needed if you want the optional 5-hour usage readout in the pill. | | paid services | **none added by this plugin** — the 5h usage endpoint is part of the user's existing MiniMax account, not a separate service | @@ -267,7 +289,7 @@ when ALL of the following are true: When all three are true, the detector makes **one** GET to: -- `https://api.minimax.io/v1/coding_plan/remains` (HTTPS, no credentials in +- `https://api.minimaxi.com/v1/coding_plan/remains` (HTTPS, no credentials in the URL, no fragment, body is a small JSON object) The response is parsed and only two numbers are written to @@ -292,7 +314,7 @@ the 5-hour usage readout in the pill. | `config.json:planApiToken` | `set-token.ps1 ` | `%APPDATA%\mcode-island\config.json` (plaintext) | `set-token.ps1 -Clear` or edit the file | The token is **never logged, never written to any other file, and never -sent to a host other than `api.minimax.io`**. `set-token.ps1` only writes +sent to a host other than `api.minimaxi.com`**. `set-token.ps1` only writes to `config.json`; it makes no network call. The detector only reads the token to attach as an `Authorization: Bearer ...` header on the single GET documented above. diff --git a/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/_lib.ps1 b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/_lib.ps1 index 797813ba..772a922c 100644 --- a/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/_lib.ps1 +++ b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/_lib.ps1 @@ -1,4 +1,4 @@ -# mcode-island: shared library for io.minimax.mcode Hooks scripts. +# mcode-island: shared library for io.minimax.mcode Hooks scripts. # Loaded via dot-source at the top of each event script: # . "$PSScriptRoot\_lib.ps1" # All event scripts under this directory MUST exit 0 (or 2 with a stderr @@ -12,6 +12,10 @@ $ErrorActionPreference = 'Stop' $script:PluginRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..\..')).Path $script:NotifyIsland = Join-Path $script:PluginRoot 'notify-island.ps1' +# Shared redaction helper (round-20). Lives in a lib because the detector +# is a second, independent producer of the same text and must redact too. +. (Join-Path $script:PluginRoot 'scripts\lib\Protect-Text.ps1') + function Set-ConsoleUtf8 { # Force UTF-8 so the PowerShell child that mcode spawns reads the # stdin JSON cleanly. notify-island.ps1 also does this internally, @@ -43,7 +47,10 @@ function Push-Island { [ValidateSet('idle','thinking','working','waiting','done','error')] [string]$State, - [string]$Message = '' + [string]$Message = '', + [int]$Step = -1, + [int]$Total = -1, + [string]$Detail = '' ) if (-not (Test-Path -LiteralPath $script:NotifyIsland)) { # Widget is not installed yet — silent no-op. The plugin's @@ -52,7 +59,7 @@ function Push-Island { return } try { - & $script:NotifyIsland -State $State -Message $Message 2>$null | Out-Null + & $script:NotifyIsland -State $State -Message $Message -Step $Step -Total $Total -Detail $Detail 2>$null | Out-Null } catch { # Hook must never block the agent on a notification failure. } @@ -97,10 +104,47 @@ function Format-ToolSummary { 'WebSearch' { $detail = [string]$Event.tool_input.query } 'Task' { $detail = [string]$Event.tool_input.description } 'NotebookEdit' { $detail = [string]$Event.tool_input.notebook_path } + # mcode-internal: Computer Use 抽 action + coordinate。 + # 例: "mcode-computer-use : click at (1024,768)" + # 注意:coordinate 是 array,PowerShell 默认 $OFS=' ' 会让 + # "$coord" 渲染成 "(1024 768)" 不是 "(1024,768)"。必须 + # 显式 -join ','。' ' 在 pill 上看起来像数字被截断, + # 影响用户判断坐标。 + # + # SECURITY: `tool_input.text` is whatever the user typed. It + # reaches this function verbatim, and its output is written to + # status.json, the append-only island.log, and rendered on an + # always-on-top pill. That surface is shared-screen visible, so + # any password / token / verification code typed through Computer + # Use ends up in a screenshot, a screen share, or a screen + # recording. Never echo the raw text. Report the action and a + # length only, so the pill still answers "is the agent typing?" + # without carrying the secret. + 'mcode-computer-use' { + $act = if ($Event.tool_input.action) { [string]$Event.tool_input.action } else { '' } + if ($Event.tool_input.coordinate) { + $coord = $Event.tool_input.coordinate + $coordStr = "($($coord -join ','))" + $detail = "$act at $coordStr" + } elseif ($Event.tool_input.text) { + # Length only. No substring, no length bucketing that + # could leak content shape, no echo of the value. + $len = ([string]$Event.tool_input.text).Length + $detail = "$act <$len chars, redacted>" + } else { + $detail = $act + } + } default { $detail = '' } } } if ([string]::IsNullOrEmpty($detail)) { return $tool } + # Redact before collapsing/truncating (round-20). This is the single + # choke point for every tool branch above: the result is written to + # status.json, appended to island.log, AND rendered on the pill, so + # redacting here covers all three sinks at once. Without it a Bash + # command like `export API_KEY=sk-...` reached all three verbatim. + $detail = Protect-SecretText $detail # Collapse newlines, take first 80 chars. $detail = ($detail -replace "[\r\n]+", ' ').Trim() if ($detail.Length -gt 80) { $detail = $detail.Substring(0, 77) + '...' } diff --git a/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/post-tool-use.ps1 b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/post-tool-use.ps1 index 034a31e5..c1cc0a65 100644 --- a/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/post-tool-use.ps1 +++ b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/post-tool-use.ps1 @@ -1,9 +1,12 @@ -# Hook: PostToolUse +# Hook: PostToolUse # Event: io.minimax.mcode / PostToolUse # State: done / error # Note: Fires after every tool call returns. Heuristic: if the # tool_result is empty or matches an error pattern, push # error; otherwise push done. Self-push calls are filtered. +# Per-tool summary (Format-ToolSummary) is split into +# Message=" ok|failed" and Detail=, so the pill +# renders "Bash ok · ls -la /tmp" instead of just "Bash ok". . "$PSScriptRoot\_lib.ps1" $evt = Read-HookStdin if (Test-IsSelfPush $evt) { exit 0 } @@ -20,9 +23,27 @@ if ($null -eq $result) { elseif ($s -match '^\s*(Error|ERROR|✕|Error:|\[ERROR\])') { $isError = $true } } +# Format-ToolSummary 抽 detail,但要剥掉 "tool : " 前缀,只留后半段 +$summary = Format-ToolSummary $evt +$detail = '' +if ($summary -and $summary.StartsWith("$tool : ")) { + $detail = $summary.Substring($tool.Length + 3) +} elseif ($summary -and $summary -ne $tool) { + $detail = $summary +} + +# Build-DisplayMessage 只在 Step > 0 时才用 detail(Step<=0 直接返回 +# Message),所以只传 -Detail 的话 detail 永远不显示 +#(round-19 review hetaoBackend #4)。这里给一个确定的 Step/Total, +# 渲染成 "step 1/1 · "。 +# PostToolUse 是单次工具结果,不参与多步序列,所以用 1/1 而不是 +# 猜一个更大的分母——猜错会让 pill 显示 "step 1/0"。 +$step = 1 +$total = 1 + if ($isError) { - Push-Island -State error -Message "$tool failed" + Push-Island -State error -Message "$tool failed" -Detail $detail -Step $step -Total $total } else { - Push-Island -State done -Message "$tool ok" + Push-Island -State done -Message "$tool ok" -Detail $detail -Step $step -Total $total } exit 0 diff --git a/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/pre-compact.ps1 b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/pre-compact.ps1 index 8e4460fb..e1b8b2df 100644 --- a/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/pre-compact.ps1 +++ b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/pre-compact.ps1 @@ -1,4 +1,4 @@ -# Hook: PreCompact +# Hook: PreCompact # Event: io.minimax.mcode / PreCompact # State: thinking # Note: Fires before the runtime compresses context. We push diff --git a/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/session-end.ps1 b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/session-end.ps1 index 214f5629..c82be00a 100644 --- a/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/session-end.ps1 +++ b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/session-end.ps1 @@ -1,4 +1,4 @@ -# Hook: SessionEnd +# Hook: SessionEnd # Event: io.minimax.mcode / SessionEnd # State: idle # Note: Fires when the runtime terminates a session. We push idle diff --git a/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/stop.ps1 b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/stop.ps1 index 0637c436..c75d7026 100644 --- a/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/stop.ps1 +++ b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/stop.ps1 @@ -1,4 +1,4 @@ -# Hook: Stop +# Hook: Stop # Event: io.minimax.mcode / Stop # State: done # Note: Fires when the agent finishes a turn (one model response, diff --git a/plugins/antianqi/mcode-island/mcode-island.ps1 b/plugins/antianqi/mcode-island/mcode-island.ps1 index 898322a7..3cfeafc3 100644 --- a/plugins/antianqi/mcode-island/mcode-island.ps1 +++ b/plugins/antianqi/mcode-island/mcode-island.ps1 @@ -1,14 +1,39 @@ -# mcode 灵动岛 v1 - WPF + PowerShell +# mcode 灵动岛 v1 - WPF + PowerShell # 用法:右键 → 用 PowerShell 运行;或通过 start-island.ps1 启动 $ErrorActionPreference = 'Stop' [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 -# 调试日志(写到 %APPDATA%\mcode-island\widget.log,最后 1KB 即可) -$script:dbg = Join-Path $env:APPDATA 'mcode-island\widget.log' +# 调试日志(写到 %APPDATA%\mcode-island\widget.log,上限 1MB) +# +# 2026-08-22 起这个函数一直是裸 Add-Content,头注释写的"最后 1KB 即可" +# 从来没有被实现过:38 天累积到 37MB,活跃使用时约 3.6MB/天(POLL 行)。 +# 现在真的实现这个上限。 +# +# 检查不每次调用都做。Add-Content 是 O(1) 追加,先用计数器累计到 1/4 +# 阈值才 stat 一次文件,避免在 400ms 轮询路径上反复摸磁盘。 +$script:dbg = Join-Path $env:APPDATA 'mcode-island\widget.log' +$script:dbgMaxBytes = 1MB +$script:dbgKeepLines = 300 +$script:dbgPending = 0 function Dbg($msg) { $ts = (Get-Date).ToString('HH:mm:ss.fff') - "[$ts] $msg" | Add-Content -Path $script:dbg -Encoding UTF8 + $line = "[$ts] $msg" + Add-Content -Path $script:dbg -Value $line -Encoding UTF8 + $script:dbgPending += $line.Length + 2 + if ($script:dbgPending -ge [int]($script:dbgMaxBytes / 4)) { + $script:dbgPending = 0 + try { + $fi = Get-Item -LiteralPath $script:dbg -ErrorAction Stop + if ($fi.Length -gt $script:dbgMaxBytes) { + $keep = @(Get-Content -LiteralPath $script:dbg -Tail $script:dbgKeepLines -Encoding UTF8) + [System.IO.File]::WriteAllLines( + $script:dbg, $keep, (New-Object System.Text.UTF8Encoding($false))) + } + } catch { + # 截断失败不能让 widget 挂掉;下一个阈值周期会再试。 + } + } } Dbg "PID=$PID APART=$([System.Threading.Thread]::CurrentThread.ApartmentState)" @@ -73,9 +98,37 @@ public class WinAPI { [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId); [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 IntPtr MonitorFromWindow(IntPtr hWnd, uint dwFlags); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern bool GetMonitorInfoW(IntPtr hMonitor, ref MONITORINFO lpmi); + [StructLayout(LayoutKind.Sequential)] + public struct RECT { public int Left, Top, Right, Bottom; } + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public struct MONITORINFO { + public int cbSize; + public RECT rcMonitor; + public RECT rcWork; + public uint dwFlags; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] public string szDevice; + } public delegate bool EnumProc(IntPtr hWnd, IntPtr lParam); public static readonly IntPtr HWND_TOPMOST = new IntPtr(-1); + public static readonly IntPtr HWND_TOP = new IntPtr(0); public const uint SWP_NOACTIVATE = 0x0010; + public const uint SWP_NOZORDER = 0x0004; + public const uint SWP_NOSIZE = 0x0001; + public const uint SWP_NOMOVE = 0x0002; + public const uint MONITOR_DEFAULTTONEAREST = 0x00000002; + + // 取窗口所在 monitor 的 work area。如果失败返回 (-1,-1)-(-1,-1) 表示无效。 + public static RECT GetWorkAreaForWindow(IntPtr hWnd) { + var bad = new RECT { Left = -1, Top = -1, Right = -1, Bottom = -1 }; + IntPtr hMon = MonitorFromWindow(hWnd, MONITOR_DEFAULTTONEAREST); + if (hMon == IntPtr.Zero) return bad; + var mi = new MONITORINFO(); + mi.cbSize = System.Runtime.InteropServices.Marshal.SizeOf(mi); + if (!GetMonitorInfoW(hMon, ref mi)) return bad; + return mi.rcWork; + } // 找 pid 的第一个可见窗口 public static IntPtr FindVisibleWindowForPid(uint targetPid) { @@ -259,6 +312,23 @@ $stateMap = @{ error = @{ dot='#FFEF4444'; ring='#FFEF4444'; label='mcode · 出错'; icon='✕' } } +# 工具家族配色:working 态下用 family 色盖掉 state 色,一眼分辨"在读"和"在改"。 +# 只覆盖 dot/ring(视觉识别),不动 stateText 文案。 +# family 名与 detector 的 $TOOL_FAMILIES 值一一对应;缺 family 时回落到 state 色。 +# +# 色相分布刻意拉开:绿(shell) / 蓝(read) / 黄(write) / 紫(search) / 青(task) / +# 粉(web) / 灰蓝(plan)。plan 早先用的是 #8B5CF6,和 search 的 #A855F7 在深色 +# pill 上几乎分不开,换成去饱和的灰蓝。 +$familyMap = @{ + shell = '#FF22C55E' # bash / shell → 绿 + read = '#FF3B82F6' # read → 蓝 + write = '#FFEAB308' # edit / write → 黄 + search = '#FFA855F7' # grep / glob → 紫 + task = '#FF06B6D4' # task → 青 + web = '#FFEC4899' # web_search/fetch → 粉 + plan = '#FF94A3B8' # todowrite → 灰蓝 +} + # 颜色转 brush function C($hex) { return (New-Object System.Windows.Media.SolidColorBrush([System.Windows.Media.ColorConverter]::ConvertFromString($hex))) } @@ -370,6 +440,34 @@ function Stop-IndeterminateShimmer { $script:progressShimmerTransform.X = -130 # 重置到起点 } +# Sub-step 渲染("step N[/M] · detail"): +# Step > 0 + Total > 0 → "step 3/12 · fill username" +# Step > 0 + Total <= 0 → "step 3 · fill username" +# Step > 0 + Detail 空 → "step 3/12" +# Step <= 0 → 原 Message 字段 +# 这样 message 字段保持"工具名"("Bash ok"),detail 字段填具体动作, +# 渲染时拼成 "step 3/12 · Bash ok · fill username" 或者更精确的 +# "step 3/12 · fill username"(detail 存在时优先覆盖 message)。 +# 注意:detail 非空时**完全替换** message,避免双重信息("Bash ok · ls -la")。 +function Build-DisplayMessage { + param( + [string]$Message, + [int]$Step, + [int]$Total, + [string]$Detail + ) + $base = if ($Message) { $Message } else { '' } + if ($Step -le 0) { return $base } + + $stepStr = if ($Total -gt 0) { "step $Step/$Total" } else { "step $Step" } + if ($Detail) { + # detail 非空时优先用 detail(agent 已经表达了"我在做什么") + return "$stepStr · $Detail" + } + # detail 空但 step 给出 → 只显示 step,避免重复 message 造成噪声 + return $stepStr +} + # 状态更新 # Progress 取值约定(跟 notify-island.ps1 / detector 对齐): # -1 → 没有进度信息,进度条隐藏 @@ -377,6 +475,14 @@ function Stop-IndeterminateShimmer { # Usage5h:剩余百分比(0..100);-2 = 未提供 # Usage5hResetMs:距下次 5h 刷新的毫秒数;0 = 未知 # TodoProgress:todowrite 列表的完成百分比(0..100);-2 = 未提供 +# Step/Total/Detail:sub-step 进度(agent 自报),参 Build-DisplayMessage +# - 优先级:显式 Progress > TodoProgress > shimmer +# - 即:agent 直接传 progress 最高;否则如果有 todo 列表就用 todo 完成度;都没就 shimmer 动画 +# -1 → 没有进度信息,进度条隐藏 +# 0..100 → 百分比,0=空条,100=满条;超出范围会被 clamp +# Usage5h:剩余百分比(0..100);-2 = 未提供 +# Usage5hResetMs:距下次 5h 刷新的毫秒数;0 = 未知 +# TodoProgress:todowrite 列表的完成百分比(0..100);-2 = 未提供 # - 优先级:显式 Progress > TodoProgress > shimmer # - 即:agent 直接传 progress 最高;否则如果有 todo 列表就用 todo 完成度;都没就 shimmer 动画 function Update-State { @@ -386,14 +492,26 @@ function Update-State { [int]$Progress = -1, [int]$Usage5h = -2, [int]$Usage5hResetMs = 0, - [int]$TodoProgress = -2 + [int]$TodoProgress = -2, + [int]$Step = -1, + [int]$Total = -1, + [string]$Detail = '', + [string]$Family = '' ) $s = $script:stateMap[$State] if (!$s) { $s = $script:stateMap['idle'] } - $script:statusDot.Fill = C $s.dot - $script:pulseRing.Fill = C $s.ring + # 家族色只作用于 active 态(thinking/working/waiting),这样 done 绿 / error 红 + # 依然是"结果"语义,家族色只用来区分"在做什么"。 + $dotHex = $s.dot + $ringHex = $s.ring + if ($Family -and $State -in @('thinking','working','waiting')) { + $famHex = $script:familyMap[$Family] + if ($famHex) { $dotHex = $famHex; $ringHex = $famHex } + } + $script:statusDot.Fill = C $dotHex + $script:pulseRing.Fill = C $ringHex $script:stateText.Text = $s.label - $script:messageText.Text = if ($Message) { $Message } else { '' } + $script:messageText.Text = Build-DisplayMessage -Message $Message -Step $Step -Total $Total -Detail $Detail $script:actionIcon.Text = $s.icon if ($State -in @('thinking','working','waiting')) { Start-Pulse } else { Stop-Pulse } @@ -447,20 +565,20 @@ function Update-State { $script:progressFill.Visibility = 'Visible' $script:progressIndeterminate.Visibility = 'Collapsed' $script:progressScale.ScaleX = $clamped / 100.0 - $script:progressFill.Background = C $s.dot + $script:progressFill.Background = C $dotHex Stop-IndeterminateShimmer } elseif ($isActive -and $hasTodoProgress) { $script:progressBar.Visibility = 'Visible' $script:progressFill.Visibility = 'Visible' $script:progressIndeterminate.Visibility = 'Collapsed' $script:progressScale.ScaleX = $todoClamped / 100.0 - $script:progressFill.Background = C $s.dot + $script:progressFill.Background = C $dotHex Stop-IndeterminateShimmer } elseif ($isActive) { $script:progressBar.Visibility = 'Visible' $script:progressFill.Visibility = 'Collapsed' $script:progressIndeterminate.Visibility = 'Visible' - $script:progressShimmer.Fill = C $s.dot + $script:progressShimmer.Fill = C $dotHex $script:progressScale.ScaleX = 0 Start-IndeterminateShimmer } else { @@ -477,10 +595,12 @@ function Update-State { "[$ts] $State :: $Message$progTag" | Add-Content -Path $script:logFile -Encoding UTF8 } -# 切回调用方窗口(点击 pill 时调用) -function Focus-CallerWindow { +# 解析调用方窗口(caller.json → targetHwnd / targetPid)。 +# 处理三种死法:hwnd 死了 / 进程死了(fallback 到父进程 terminal)/ +# hwnd 被销毁重建。返回 [PSCustomObject]@{ Hwnd; Pid; Exe } 或 $null。 +function Resolve-CallerWindow { $callerFile = Join-Path $env:APPDATA 'mcode-island\caller.json' - if (!(Test-Path $callerFile)) { Dbg 'FOCUS: no caller file'; return } + if (!(Test-Path $callerFile)) { Dbg 'RESOLVE: no caller file'; return $null } $hwnd = [IntPtr]::Zero $targetPid = 0 @@ -491,72 +611,133 @@ function Focus-CallerWindow { $targetPid = [int]$data.targetPid $targetExe = if ($data.targetExe) { [string]$data.targetExe } else { '' } } catch { - Dbg "FOCUS: caller.json parse error" - return + Dbg "RESOLVE: caller.json parse error" + return $null } - if ($targetPid -le 0) { Dbg 'FOCUS: no target'; return } + if ($targetPid -le 0) { Dbg 'RESOLVE: no target'; return $null } - # 1) 检查 hwnd 是否还活着 + # 1) hwnd 死了 → 重找 if ($hwnd -ne [IntPtr]::Zero -and -not [WinAPI]::IsWindow($hwnd)) { - Dbg "FOCUS: hwnd $hwnd dead, re-resolving" + Dbg "RESOLVE: hwnd $hwnd dead, re-resolving" $hwnd = [IntPtr]::Zero } - # 2) 进程死了 → 找它的父进程(terminal)兜底 + # 2) 进程死了 → fallback 到父进程(terminal)兜底 $proc = Get-Process -Id $targetPid -ErrorAction SilentlyContinue if (-not $proc) { - Dbg "FOCUS: target PID $targetPid gone, finding parent (terminal)" + Dbg "RESOLVE: target PID $targetPid gone, finding parent (terminal)" $parent = Get-CimInstance Win32_Process -Filter "ProcessId=$targetPid" -ErrorAction SilentlyContinue if ($parent -and $parent.ParentProcessId -and $parent.ParentProcessId -gt 0) { $parentProc = Get-Process -Id ([int]$parent.ParentProcessId) -ErrorAction SilentlyContinue if ($parentProc) { $targetPid = $parentProc.Id $targetExe = $parentProc.ProcessName - # 优先用 MainWindowHandle,失败就用第一个可见窗口 if ($parentProc.MainWindowHandle -ne [IntPtr]::Zero) { $hwnd = $parentProc.MainWindowHandle } else { $hwnd = [WinAPI]::FindVisibleWindowForPid([uint32]$targetPid) } - Dbg "FOCUS: fall back to parent $($parentProc.ProcessName) PID=$targetPid hwnd=$hwnd" + Dbg "RESOLVE: fall back to parent $($parentProc.ProcessName) PID=$targetPid hwnd=$hwnd" } } if ($hwnd -eq [IntPtr]::Zero) { - Dbg 'FOCUS: no parent fallback available' - return + Dbg 'RESOLVE: no parent fallback available' + return $null } } - # 3) 进程还在但 hwnd 死了(被销毁/重建)→ 找进程的第一个可见窗口 + + # 3) 进程还在但 hwnd 死了(被销毁/重建)→ 找新可见窗口 if ($hwnd -eq [IntPtr]::Zero -or -not [WinAPI]::IsWindow($hwnd)) { - Dbg "FOCUS: hwnd invalid, finding new visible window for PID $targetPid ($targetExe)" + Dbg "RESOLVE: hwnd invalid, finding new visible window for PID $targetPid ($targetExe)" $hwnd = [WinAPI]::FindVisibleWindowForPid([uint32]$targetPid) if ($hwnd -eq [IntPtr]::Zero) { - Dbg 'FOCUS: no visible window found for target process' - return + Dbg 'RESOLVE: no visible window found for target process' + return $null } - Dbg "FOCUS: re-resolved to hwnd $hwnd" + Dbg "RESOLVE: re-resolved to hwnd $hwnd" } + return [PSCustomObject]@{ Hwnd = $hwnd; Pid = $targetPid; Exe = $targetExe } +} + +# 强制把调用方窗口拉到前台(modern Windows 要求 AllowSetForegroundWindow)。 +# 不管当前 visible 与否,都做 show + focus。Focus-CallerWindow 保留, +# 因为它是 Resolve-CallerWindow + 强制 show 的最小封装,可用于自动聚焦 +# 流程(needs_input 状态自动弹窗那种)。 +function Focus-CallerWindow { + $r = Resolve-CallerWindow + if (-not $r) { return } + try { - # 1) 授权目标进程可以切前台(modern Windows 强制) - [WinAPI]::AllowSetForegroundWindow([uint32]$targetPid) | Out-Null - # 2) 最小化就还原 - if ([WinAPI]::IsIconic($hwnd)) { - [WinAPI]::ShowWindow($hwnd, 9) | Out-Null # SW_RESTORE + [WinAPI]::AllowSetForegroundWindow([uint32]$r.Pid) | Out-Null + if ([WinAPI]::IsIconic($r.Hwnd)) { + [WinAPI]::ShowWindow($r.Hwnd, 9) | Out-Null # SW_RESTORE } - # 3) 设顶 - [WinAPI]::SetWindowPos($hwnd, [WinAPI]::HWND_TOPMOST, 0, 0, 0, 0, [WinAPI]::SWP_NOACTIVATE) | Out-Null - [WinAPI]::SetWindowPos($hwnd, [IntPtr]::new(-2), 0, 0, 0, 0, [WinAPI]::SWP_NOACTIVATE) | Out-Null # HWND_NOTOPMOST - # 4) 抢焦点 - [WinAPI]::BringWindowToTop($hwnd) | Out-Null - [WinAPI]::SetForegroundWindow($hwnd) | Out-Null - $proc2 = Get-Process -Id $targetPid -ErrorAction SilentlyContinue - Dbg ("FOCUS OK: target=" + $proc2.ProcessName + " PID=" + $targetPid + " hwnd=" + $hwnd) + [WinAPI]::SetWindowPos($r.Hwnd, [WinAPI]::HWND_TOPMOST, 0, 0, 0, 0, [WinAPI]::SWP_NOACTIVATE) | Out-Null + [WinAPI]::SetWindowPos($r.Hwnd, [IntPtr]::new(-2), 0, 0, 0, 0, [WinAPI]::SWP_NOACTIVATE) | Out-Null # HWND_NOTOPMOST + [WinAPI]::BringWindowToTop($r.Hwnd) | Out-Null + [WinAPI]::SetForegroundWindow($r.Hwnd) | Out-Null + $proc2 = Get-Process -Id $r.Pid -ErrorAction SilentlyContinue + Dbg ("FOCUS OK: target=" + $proc2.ProcessName + " PID=" + $r.Pid + " hwnd=" + $r.Hwnd) } catch { Dbg "FOCUS FAIL: $($_.Exception.Message)" } } +# 单击 pill toggle:可见 → 隐藏;隐藏 → 全屏还原 + 抢焦点。 +# 设计取舍 (round-14+15+16): +# hide 分支用 SW_HIDE (而不是 SW_MINIMIZE): +# SW_MINIMIZE 在某些终端配置下(Windows Terminal "Always show tabs on top") +# 会保留一个 thin tab-bar strip 浮在桌面顶部,不算真"藏"。 +# show 分支先 SW_MAXIMIZE (激活+最大化),再用 SetWindowPos 强制拉到 +# MonitorFromWindow+GetMonitorInfo 拿到的真实 work area(2560x1392 而 +# 不是 [Screen]::PrimaryScreen 报告的 1920x1080 — WinForms DPI 虚拟化 +# 会把 2560x1440 物理像素报成 1920x1080 逻辑像素,SW_MAXIMIZE 跟着 +# 1920x1080 走,结果 WT 只填了物理显示器的左上 75%)。 +# 最后 SetWindowPos(HWND_TOP) + BringWindowToTop 抢 z-order,绕过 +# widget PID 没有 foreground 权限的限制。 +# 状态判定: IsWindowVisible 在 SW_HIDE 和 SW_MINIMIZE 后都返回 false +# (区别是 IsIconic:SW_HIDE 后 false,SW_MINIMIZE 后 true)。toggle 只看 +# IsWindowVisible 即可,SW_MAXIMIZE 在内部正确处理两种 case。 +function Toggle-CallerWindow { + $r = Resolve-CallerWindow + if (-not $r) { return } + + try { + $isShown = [WinAPI]::IsWindowVisible($r.Hwnd) + if ($isShown) { + [WinAPI]::ShowWindow($r.Hwnd, 0) | Out-Null # SW_HIDE + Dbg "TOGGLE: hid target=$($r.Exe) PID=$($r.Pid) hwnd=$($r.Hwnd)" + } else { + # 1) SW_MAXIMIZE 激活+标记 maximized + [WinAPI]::ShowWindow($r.Hwnd, 3) | Out-Null # SW_MAXIMIZE + # 2) SetWindowPos 强制拉到 monitor work area (绕过 DPI/remembered-size 限制) + $wa = [WinAPI]::GetWorkAreaForWindow($r.Hwnd) + if ($wa.Left -ne -1) { + $cx = $wa.Right - $wa.Left + $cy = $wa.Bottom - $wa.Top + [WinAPI]::SetWindowPos($r.Hwnd, [IntPtr]::Zero, $wa.Left, $wa.Top, $cx, $cy, [WinAPI]::SWP_NOZORDER) | Out-Null + Dbg "TOGGLE: forced to work area ({0},{1}) {2}x{3}" -f $wa.Left, $wa.Top, $cx, $cy + } + # 3) 抢 z-order 到最前(SetWindowPos(HWND_TOP) 不需要 foreground 权限) + # SWP_NOSIZE:cx=0/cy=0 缺它会被 Windows 当成"resize 到 0x0", + # 触发 WT 的 min-size 兜底,变成 480x76 strip。 + # SWP_NOMOVE:X=0/Y=0 缺它会把窗口真的挪到 (0,0)。单屏时无所谓, + # 但副屏(monitor 原点非 0)上会把还原后的窗口甩到主屏左上角。 + # 同样不能加 SWP_NOZORDER——那个 flag 会让 hWndInsertAfter 被忽略, + # HWND_TOP 就白传了,BringWindowToTop 之后仍可能被别的窗口盖住。 + [WinAPI]::AllowSetForegroundWindow([uint32]$r.Pid) | Out-Null + $nofollow = [WinAPI]::SWP_NOACTIVATE -bor [WinAPI]::SWP_NOSIZE -bor [WinAPI]::SWP_NOMOVE + [WinAPI]::SetWindowPos($r.Hwnd, [WinAPI]::HWND_TOP, 0, 0, 0, 0, $nofollow) | Out-Null + [WinAPI]::BringWindowToTop($r.Hwnd) | Out-Null + [WinAPI]::SetForegroundWindow($r.Hwnd) | Out-Null + Dbg "TOGGLE: shown (maximized + work-area) target=$($r.Exe) PID=$($r.Pid) hwnd=$($r.Hwnd)" + } + } catch { + Dbg "TOGGLE FAIL: $($_.Exception.Message)" + } +} + # 手动设置焦点目标(右键菜单调用):把当前前台窗口记为 focus target function Set-FocusTarget-Current { Add-Type @" @@ -653,7 +834,7 @@ $window.Add_MouseLeftButtonUp({ if ($script:dragStart -and -not $script:didDrag) { Dbg 'CLICK detected' Flash-Click - Focus-CallerWindow + Toggle-CallerWindow } } catch { Dbg "CLICK FAIL: $($_.Exception.Message)" @@ -689,18 +870,28 @@ $timer.Add_Tick({ $script:lastStatusMtime = $mtime $data = Get-Content $statusFile -Raw -Encoding UTF8 | ConvertFrom-Json # progress 也要进 sig,否则 agent 连续推 working+相同 message+不同 progress 会被去重 + # step/total/detail 也要进 sig,否则连续推同 state 但不同 step 会被去重 $prog = if ($data.PSObject.Properties['progress']) { [int]$data.progress } else { -1 } $usage = $null $resetMs = 0 $todoP = -2 + $step = -1 + $total = -1 + $detail = '' if ($data.PSObject.Properties['usage5h'] -and $null -ne $data.usage5h) { $usage = [int]$data.usage5h } if ($data.PSObject.Properties['usage5hResetMs'] -and $null -ne $data.usage5hResetMs) { $resetMs = [int]$data.usage5hResetMs } if ($data.PSObject.Properties['todoProgress'] -and $null -ne $data.todoProgress) { $todoP = [int]$data.todoProgress } - $sig = "$($data.state)|$($data.message)|$prog|$usage|$resetMs|$todoP|$($data.ts)" + if ($data.PSObject.Properties['step'] -and $null -ne $data.step) { $step = [int]$data.step } + if ($data.PSObject.Properties['total'] -and $null -ne $data.total) { $total = [int]$data.total } + if ($data.PSObject.Properties['detail'] -and $null -ne $data.detail) { $detail = [string]$data.detail } + # family 也进 sig,否则同一工具连续 working 但换了家族不会重新上色 + $family = '' + if ($data.PSObject.Properties['family'] -and $data.family) { $family = [string]$data.family } + $sig = "$($data.state)|$($data.message)|$family|$prog|$usage|$resetMs|$todoP|$step|$total|$detail|$($data.ts)" if ($sig -eq $script:lastStatusSig) { return } $script:lastStatusSig = $sig - Dbg "POLL: $($data.state) :: $($data.message) (progress=$prog usage5h=$usage resetMs=$resetMs todoProgress=$todoP)" - Update-State -State $data.state -Message $data.message -Progress $prog -Usage5h $usage -Usage5hResetMs $resetMs -TodoProgress $todoP + Dbg "POLL: $($data.state) :: $($data.message) family=$family step=$step/$total detail=$detail (progress=$prog usage5h=$usage resetMs=$resetMs todoProgress=$todoP)" + Update-State -State $data.state -Message $data.message -Progress $prog -Usage5h $usage -Usage5hResetMs $resetMs -TodoProgress $todoP -Step $step -Total $total -Detail $detail -Family $family } catch { Dbg "POLL ERR: $($_.Exception.Message)" } @@ -717,12 +908,20 @@ if (Test-Path $statusFile) { $initUsage = $null $initReset = 0 $initTodo = -2 + $initStep = -1 + $initTotal = -1 + $initDetail = '' if ($init.PSObject.Properties['usage5h'] -and $null -ne $init.usage5h) { $initUsage = [int]$init.usage5h } if ($init.PSObject.Properties['usage5hResetMs'] -and $null -ne $init.usage5hResetMs) { $initReset = [int]$init.usage5hResetMs } if ($init.PSObject.Properties['todoProgress'] -and $null -ne $init.todoProgress) { $initTodo = [int]$init.todoProgress } - $script:lastStatusSig = "$($init.state)|$($init.message)|$initProg|$initUsage|$initReset|$initTodo|$($init.ts)" + if ($init.PSObject.Properties['step'] -and $null -ne $init.step) { $initStep = [int]$init.step } + if ($init.PSObject.Properties['total'] -and $null -ne $init.total) { $initTotal = [int]$init.total } + if ($init.PSObject.Properties['detail'] -and $null -ne $init.detail) { $initDetail = [string]$init.detail } + $initFamily = '' + if ($init.PSObject.Properties['family'] -and $init.family) { $initFamily = [string]$init.family } + $script:lastStatusSig = "$($init.state)|$($init.message)|$initFamily|$initProg|$initUsage|$initReset|$initTodo|$initStep|$initTotal|$initDetail|$($init.ts)" $script:lastStatusMtime = (Get-Item $statusFile).LastWriteTimeUtc.Ticks - Update-State -State $init.state -Message $init.message -Progress $initProg -Usage5h $initUsage -Usage5hResetMs $initReset -TodoProgress $initTodo + Update-State -State $init.state -Message $init.message -Progress $initProg -Usage5h $initUsage -Usage5hResetMs $initReset -TodoProgress $initTodo -Step $initStep -Total $initTotal -Detail $initDetail -Family $initFamily } catch {} } else { Update-State -State 'idle' -Message '' diff --git a/plugins/antianqi/mcode-island/mcode-status-detect.ps1 b/plugins/antianqi/mcode-island/mcode-status-detect.ps1 index e0068f0c..e550c060 100644 --- a/plugins/antianqi/mcode-island/mcode-status-detect.ps1 +++ b/plugins/antianqi/mcode-island/mcode-status-detect.ps1 @@ -67,6 +67,7 @@ $S_COMPACTION = _s (0x63,0x6F,0x6D,0x70,0x61,0x63,0x74,0x69,0x6F,0x6E,0x53,0x75, $S_IDLE = _s (0x69,0x64,0x6C,0x65) $S_THINKING = _s (0x74,0x68,0x69,0x6E,0x6B,0x69,0x6E,0x67) $S_WORKING = _s (0x77,0x6F,0x72,0x6B,0x69,0x6E,0x67) +$S_WAITING = _s (0x77,0x61,0x69,0x74,0x69,0x6E,0x67) $S_DONE = _s (0x64,0x6F,0x6E,0x65) $S_ERROR = _s (0x65,0x72,0x72,0x6F,0x72) # content type discriminators @@ -78,8 +79,6 @@ $S_DETECTOR = _s (0x64,0x65,0x74,0x65,0x63,0x74,0x6F,0x72) # messages (UTF-8 encoded Chinese) $MSG_USER_WAIT = _s (0xE7,0xAD,0x89,0xE7,0x94,0xA8,0xE6,0x88,0xB7) # 等用户 $MSG_AGENT_DONE = _s (0x61,0x67,0x65,0x6E,0x74,0x20,0xE5,0x88,0x9A,0xE5,0x9B,0x9E,0xE5,0xA4,0x8D,0xEF,0xBC,0x8C,0xE7,0xAD,0x89,0xE7,0x94,0xA8,0xE6,0x88,0xB7) # agent 刚回复,等用户 -$MSG_FAIL = _s (0xE5,0xA4,0xB1,0xE8,0xB4,0xA5) # 失败 -$MSG_OK = _s (0xE5,0xAE,0x8C,0xE6,0x88,0x90) # 完成 $MSG_COMPACT = _s (0x73,0x65,0x73,0x73,0x69,0x6F,0x6E,0x20,0xE5,0x8E,0x8B,0xE7,0xBC,0xA9) # session 压缩 $MSG_MCODE_EXIT = _s (0x6D,0x63,0x6F,0x64,0x65,0x20,0xE8,0xBF,0x9B,0xE7,0xA8,0x8B,0xE5,0xB7,0xB2,0xE9,0x80,0x80,0xE5,0x87,0xBA) # mcode 进程已退出 $MSG_IDLE_FMT = _s (0xE5,0xB7,0xB2,0xE9,0x9D,0x99,0xE9,0x9C,0xA8,0x20,0x7B,0x30,0x7D,0x73) # 已静默 {0}s @@ -94,6 +93,51 @@ $R_TAKEOVER = _s (0x74,0x61,0x6B,0x65,0x6F,0x76,0x65,0x72,0x20,0x74,0x6F,0x # session log file names (优先 ledger) $FNAME_LEDGER = _s (0x6C,0x65,0x64,0x67,0x65,0x72,0x2E,0x6A,0x73,0x6F,0x6E,0x6C) # ledger.jsonl $FNAME_MESSAGES = _s (0x6D,0x65,0x73,0x73,0x61,0x67,0x65,0x73,0x2E,0x6A,0x73,0x6F,0x6E,0x6C) # messages.jsonl +# tool action verbs (ASCII — matches the mcode CLI TUI descriptor table HL) +# Mirrors @minimax-ai/code launcher-GHPADSKI.js HL[] so the pill shows the same +# present-tense verb ("Running") while the tool is in flight and the same +# past-tense verb ("Ran") once it returns, instead of a generic tool name. +$TOOL_ACTIONS = @{ + 'bash' = @{ running='Running'; done='Ran'; fail='Command failed' } + 'shell' = @{ running='Running'; done='Ran'; fail='Command failed' } + 'edit' = @{ running='Editing'; done='Edited'; fail='Edit failed' } + 'write' = @{ running='Writing'; done='Wrote'; fail='Write failed' } + 'read' = @{ running='Reading'; done='Read'; fail='Read failed' } + 'grep' = @{ running='Searching'; done='Searched'; fail='Search failed' } + 'search' = @{ running='Searching'; done='Searched'; fail='Search failed' } + 'glob' = @{ running='Listing'; done='Listed'; fail='List failed' } + 'list' = @{ running='Listing'; done='Listed'; fail='List failed' } + 'task' = @{ running='Delegating'; done='Delegated'; fail='Delegation failed' } + 'web_search' = @{ running='Web searching'; done='Web searched'; fail='Web search failed' } + 'web_fetch' = @{ running='Fetching'; done='Fetched'; fail='Fetch failed' } + 'todowrite' = @{ running='Planning'; done='Planned'; fail='Plan update failed' } + 'task_output' = @{ running='Reading task'; done='Read task'; fail='Task read failed' } + 'task_append' = @{ running='Steering task'; done='Steered task'; fail='Task steer failed' } + 'task_query' = @{ running='Checking tasks'; done='Checked tasks'; fail='Task check failed' } + 'ask_user' = @{ running='Asking'; done='Asked'; fail='Question failed' } + 'request_feature_enable' = @{ running='Requesting access'; done='Access requested'; fail='Access request failed' } +} +# Tool family — the pill tints its dot by family instead of by state, so a +# read looks different from a write at a glance. Mirrors the `family` field +# of the mcode CLI TUI descriptor table (launcher-GHPADSKI.js HL[]). +# Unknown tools get no family (null) and fall back to the state color. +$TOOL_FAMILIES = @{ + 'bash' = 'shell'; 'shell' = 'shell' + 'read' = 'read' + 'edit' = 'write'; 'write' = 'write' + 'grep' = 'search'; 'search' = 'search'; 'glob' = 'search'; 'list' = 'search' + 'task' = 'task'; 'task_output' = 'task'; 'task_append' = 'task' + # ask_user is deliberately absent: it maps to the `waiting` state (see + # Infer-State), and a waiting pill should wear the state color, not a family + # tint. Giving it 'task' would paint the "come back, I'm blocked" signal in + # the same cyan as an in-flight delegation. + 'web_search' = 'web'; 'web_fetch' = 'web' + 'todowrite' = 'plan' + 'request_feature_enable' = 'plan' +} +# default for tools not in the table (kept lowercase so it reads like a name) +$TOOL_FALLBACK_RUNNING = _s (0x55,0x73,0x69,0x6E,0x67) # Using +$TOOL_FALLBACK_DONE = _s (0x55,0x73,0x65,0x64) # Used # mcode node cli.js path fragment for cmdline match $CLI_FRAGMENT = _s (0x40,0x6D,0x69,0x6E,0x69,0x6D,0x61,0x78,0x2D,0x61,0x69,0x2F,0x63,0x6F,0x64,0x65,0x2F,0x63,0x6C,0x69,0x2E,0x6A,0x73) # @minimax-ai/code/cli.js # .mcode-active directory name @@ -120,6 +164,11 @@ $cfgFile = Join-Path $configDir 'config.json' # mcode install on the runner. Dot-source the lib here so the # main loop's Refresh-5hUsage can call Get-5hUsage directly. . "$PSScriptRoot/scripts/lib/Get-5hUsage.ps1" +# Shared redaction helper (round-20). The detector is the SECOND producer +# of tool text -- it re-derives messages from mcode's session log rather +# than from a hook event, so it needs the same redaction the hook path +# applies. Wiring only the hook left the raw command in island.log. +. "$PSScriptRoot/scripts/lib/Protect-Text.ps1" $PLAN_API_TTL = [TimeSpan]::FromSeconds(60) $script:plan5hToken = $null if ($env:MINIMAX_OAUTH_TOKEN) { $script:plan5hToken = $env:MINIMAX_OAUTH_TOKEN } @@ -222,10 +271,37 @@ $script:lastMcodePidAt = [DateTime]::MinValue $script:lastLatestFile = $null $script:lastLatestFileAt = [DateTime]::MinValue +# island.log 上限(round-20) +# +# island.log 一直是裸 Add-Content,从 2026-08-22 起没有任何截断, +# 38 天累积到 18MB。日志内容包含从 session log 推断出来的工具参数, +# 所以"无上限保留"同时是磁盘问题也是留存问题。这里给一个硬上限: +# 超过阈值时只保留最后 $logKeepLines 行。 +# +# 阈值检查不每次调用都做:Add-Content 是 O(1) 追加,先用计数器累计到 +# 1/4 阈值才 stat 一次文件,避免在 400ms 轮询路径上反复摸磁盘。 +$script:logMaxBytes = 1MB +$script:logKeepLines = 300 +$script:logPending = 0 + function Log-Line($msg) { $ts = (Get-Date).ToString('HH:mm:ss') $line = "[$ts] [detect] $msg" Add-Content -Path $logFile -Value $line -Encoding UTF8 + $script:logPending += $line.Length + 2 + if ($script:logPending -ge [int]($script:logMaxBytes / 4)) { + $script:logPending = 0 + try { + $fi = Get-Item -LiteralPath $logFile -ErrorAction Stop + if ($fi.Length -gt $script:logMaxBytes) { + $keep = @(Get-Content -LiteralPath $logFile -Tail $script:logKeepLines -Encoding UTF8) + [System.IO.File]::WriteAllLines( + $logFile, $keep, (New-Object System.Text.UTF8Encoding($false))) + } + } catch { + # 截断失败不能拖垮检测循环;下一轮阈值到了会再试一次。 + } + } if (-not $Once) { Write-Output $line } } @@ -327,6 +403,170 @@ function Read-LastMessage($file) { } } +# Normalize a tool name to the key shape used by $TOOL_ACTIONS +# (mcode's own Wt(): trim, lowercase, '-' -> '_'). Untrusted agent-supplied +# strings, so guard the length: a 4KB "tool name" must not become a map key. +function Get-ToolKey($name) { + if (-not $name) { return '' } + $n = ([string]$name).Trim().ToLowerInvariant().Replace('-', '_') + if ($n.Length -gt 64) { $n = $n.Substring(0, 64) } + return $n +} + +# Resolve the action verb for a tool in a given phase. +# phase: 'running' | 'done' | 'fail' +# Returns '' for an unknown tool so callers can fall back to the raw name. +function Get-ToolVerb($name, $phase) { + $key = Get-ToolKey $name + if (-not $key) { return '' } + $entry = $TOOL_ACTIONS[$key] + if (-not $entry) { return '' } + switch ($phase) { + 'running' { return [string]$entry.running } + 'done' { return [string]$entry.done } + 'fail' { return [string]$entry.fail } + } + return '' +} + +# Verb for an unknown tool: "Using " / "Used ", so the pill +# never falls back to a bare identifier with no signal about the phase. +function Get-ToolVerbFallback($name, $phase) { + $key = Get-ToolKey $name + if (-not $key) { return '' } + if ($phase -eq 'running') { return "$TOOL_FALLBACK_RUNNING $key" } + return "$TOOL_FALLBACK_DONE $key" +} + +function Get-ToolFamily($name) { + $key = Get-ToolKey $name + if (-not $key) { return $null } + $fam = $TOOL_FAMILIES[$key] + if ($fam) { return [string]$fam } + return $null +} + +# Human-readable one-liner for a tool's arguments (round-20 #6). +# +# The running-state message used to be built as +# "$verb " + (ConvertTo-Json $args -Compress) truncated to 60 chars +# which put this on an always-on-top, one-line pill: +# +# Running {"command":"$ErrorActionPreference=\u0027Continue\u0027\n... +# +# Escaped quotes, a JSON key, and a truncation that can land mid-token. +# `skill` is not in $TOOL_ACTIONS or $TOOL_FAMILIES, so it always took +# that path -- one of the most frequent tools on screen was also the +# least readable. +# +# Contract: return the single most informative field for the tool, or '' +# when there is nothing worth showing. Callers render the verb alone in +# that case. Raw JSON is never returned -- there is no fallback to it. +# +# Unknown tools get a generic sweep of the common field names before +# giving up, because the tool taxonomy is hand-maintained and does not +# cover every tool mcode ships. +# NB: the second parameter must NOT be called `$args`. That name is a +# reserved automatic variable in PowerShell, and binding a parameter to +# it yields the unbound-argument Object[] rather than the object passed in +# -- verified: `function T($n, $args) { $args.command }` called with +# `[PSCustomObject]@{command='npm test'}` returns an empty string, because +# the parameter arrives as Object[]. Symptom is a summary that is empty for +# every single tool, which looks like "no data" rather than "bad binding". +function Format-ToolArgs($name, $toolArgs) { + if (-not $toolArgs) { return '' } + $key = ([string]$name).ToLowerInvariant() + $raw = '' + switch ($key) { + 'bash' { $raw = [string]$toolArgs.command } + 'shell' { $raw = [string]$toolArgs.command } + 'read' { $raw = [string]$toolArgs.file_path } + 'write' { $raw = [string]$toolArgs.file_path } + 'edit' { $raw = [string]$toolArgs.file_path } + 'notebookedit' { $raw = [string]$toolArgs.notebook_path } + 'grep' { $raw = [string]$toolArgs.pattern } + 'glob' { $raw = [string]$toolArgs.pattern } + 'list' { $raw = [string]$toolArgs.path } + 'search' { $raw = [string]$toolArgs.query } + 'web_search' { $raw = [string]$toolArgs.query } + 'web_fetch' { $raw = [string]$toolArgs.url } + 'skill' { $raw = [string]$toolArgs.name } + 'task' { $raw = [string]$toolArgs.description } + 'task_output' { $raw = [string]$toolArgs.task_id } + 'task_append' { $raw = [string]$toolArgs.task_id } + 'task_query' { $raw = [string]$toolArgs.task_id } + default { + foreach ($f in 'name','command','file_path','path','pattern','query','url','description','task_id') { + if ($toolArgs.PSObject.Properties[$f]) { + $v = [string]$toolArgs.$f + if (-not [string]::IsNullOrWhiteSpace($v)) { $raw = $v; break } + } + } + } + } + if ([string]::IsNullOrWhiteSpace($raw)) { return '' } + + # The pill is a single line, and an agent command is usually a short + # script rather than one line. Prefer the first line that actually + # EXECUTES something over the first line that merely sets the stage. + # + # `$ErrorActionPreference='Continue'` is the opening line of most agent + # commands and says nothing about what is being worked on; reading it + # off a status pill is indistinguishable from the pill being stuck. The + # first real command below it -- `npm test`, `Get-ChildItem` -- is the + # line the user actually wants. + $fallback = '' + $first = '' + foreach ($ln in ($raw -split "`r?`n")) { + $t = $ln.Trim() + if ($t -eq '') { continue } + if ($fallback -eq '') { $fallback = $t } + if ($t.StartsWith('#')) { continue } # comment + if ($t.StartsWith('$')) { continue } # variable assignment / preference set + $first = $t + break + } + # Every line was boilerplate. Showing the assignment beats showing nothing. + if ($first -eq '') { $first = $fallback } + if ([string]::IsNullOrWhiteSpace($first)) { return '' } + + # Same redaction the hook path applies, so a credential in a command + # cannot reach the pill or island.log through this route either. + $first = Protect-SecretText $first + $first = ($first -replace '\s+', ' ').Trim() + if ($first.Length -gt 60) { $first = $first.Substring(0, 57) + $DOTS } + return $first +} + +# ask_user is the one tool whose "in flight" state means the agent is BLOCKED, +# not busy. When the questionnaire pops, nothing progresses until the user +# comes back and answers. Reporting it as `working` tells the user "leave it +# alone, it's making progress", which is the opposite of the truth -- so it +# maps to `waiting` (the same state the permission hook uses) with a question +# count, because "Asking 2 questions" is exactly the "come back" signal. +# +# Returns $null for every other tool, so the caller can fall through to the +# normal verb lookup. +function Test-IsAskUser($name) { + return ((Get-ToolKey $name) -eq 'ask_user') +} + +function Get-AskQuestionCount($toolArgs) { + # Parameter is deliberately NOT named $args: that is a PowerShell automatic + # variable, and shadowing it makes every read inside the function return the + # function's own argument list instead of the caller's value. It silently + # produced a count of 0 for every input, which degraded the message to a + # bare "Asking". + if (-not $toolArgs) { return 0 } + try { + $a = $toolArgs + if ($a -is [string]) { $a = $a | ConvertFrom-Json -ErrorAction Stop } + $qs = $a.PSObject.Properties['questions'] + if (-not $qs -or $null -eq $qs.Value) { return 0 } + return @($qs.Value).Count + } catch { return 0 } +} + function Infer-State($msg) { if (-not $msg) { return $null } # ledger.jsonl 事件格式:{kind, phase, action, ...} @@ -337,11 +577,28 @@ function Infer-State($msg) { $kind = [string]$msg.kind $phase = [string]$msg.phase if ($kind -eq $S_TOOLRESULT) { - if ($msg.isError -eq $true) { return @{ state=$S_ERROR; message="$($msg.toolName) $MSG_FAIL" } } - return @{ state=$S_DONE; message="$($msg.toolName) $MSG_OK" } + $tv = [string]$msg.toolName + if ($msg.isError -eq $true) { + $verb = Get-ToolVerb $tv 'fail' + if (-not $verb) { $verb = Get-ToolVerbFallback $tv 'fail' } + return @{ state=$S_ERROR; message=$verb; family=$null } + } + $verb = Get-ToolVerb $tv 'done' + if (-not $verb) { $verb = Get-ToolVerbFallback $tv 'done' } + return @{ state=$S_DONE; message=$verb; family=(Get-ToolFamily $tv) } } if ($kind -eq 'toolCall' -or $kind -eq $S_TOOLCALL) { - return @{ state=$S_WORKING; message="$($msg.toolName) : $($msg.action)" } + $tv = [string]$msg.toolName + if (Test-IsAskUser $tv) { + $n = Get-AskQuestionCount $msg.arguments + if ($n -gt 0) { return @{ state=$S_WAITING; message="Asking $n question$(if ($n -gt 1) { 's' })"; family=$null } } + return @{ state=$S_WAITING; message='Asking'; family=$null } + } + $verb = Get-ToolVerb $tv 'running' + if (-not $verb) { $verb = Get-ToolVerbFallback $tv 'running' } + $detail = [string]$msg.action + if ($detail) { return @{ state=$S_WORKING; message="$verb $detail"; family=(Get-ToolFamily $tv) } } + return @{ state=$S_WORKING; message=$verb; family=(Get-ToolFamily $tv) } } if ($kind -eq $S_ASSISTANT -and $phase -eq $S_THINKING) { return @{ state=$S_THINKING; message='' } @@ -371,12 +628,21 @@ function Infer-State($msg) { if ($hasTool) { $tool = $hasTool.name $args = $hasTool.arguments - if ($args) { - # ConvertTo-Json is a single .NET call; keep it (no pipeline leak). - $argsJson = $args | ConvertTo-Json -Compress -Depth 2 -WarningAction SilentlyContinue - if ($argsJson.Length -gt 60) { $argsJson = $argsJson.Substring(0, 57) + $DOTS } - } else { $argsJson = '' } - return @{ state=$S_WORKING; message="$tool : $argsJson" } + if (Test-IsAskUser $tool) { + $n = Get-AskQuestionCount $args + if ($n -gt 0) { return @{ state=$S_WAITING; message="Asking $n question$(if ($n -gt 1) { 's' })"; family=$null } } + return @{ state=$S_WAITING; message='Asking'; family=$null } + } + $verb = Get-ToolVerb $tool 'running' + if (-not $verb) { $verb = Get-ToolVerbFallback $tool 'running' } + # Readable summary, never raw JSON (round-20 #6). When there is + # nothing worth showing the pill wears the bare verb, which reads + # fine -- "Using" beats "Using {\"name\":\"...\"}". + $summary = Format-ToolArgs $tool $args + if ($summary) { + return @{ state=$S_WORKING; message="$verb $summary"; family=(Get-ToolFamily $tool) } + } + return @{ state=$S_WORKING; message=$verb; family=(Get-ToolFamily $tool) } } if ($hasThink -and -not $hasText) { return @{ state=$S_THINKING; message='' } } if ($hasText) { return @{ state=$S_IDLE; message=$MSG_AGENT_DONE } } @@ -386,8 +652,14 @@ function Infer-State($msg) { if ($role -eq $S_TOOLRESULT) { $tool = $m.toolName $isErr = $m.isError -eq $true - if ($isErr) { return @{ state=$S_ERROR; message="$tool $MSG_FAIL" } } - return @{ state=$S_DONE; message="$tool $MSG_OK" } + if ($isErr) { + $verb = Get-ToolVerb $tool 'fail' + if (-not $verb) { $verb = Get-ToolVerbFallback $tool 'fail' } + return @{ state=$S_ERROR; message=$verb; family=$null } + } + $verb = Get-ToolVerb $tool 'done' + if (-not $verb) { $verb = Get-ToolVerbFallback $tool 'done' } + return @{ state=$S_DONE; message=$verb; family=(Get-ToolFamily $tool) } } if ($role -eq $S_COMPACTION) { return @{ state=$S_IDLE; message=$MSG_COMPACT } } @@ -395,25 +667,73 @@ function Infer-State($msg) { return $null } -function Write-Status($state, $message) { +function Write-Status($state, $message, $family, [switch]$KeepSubStep) { $tmp = "$statusFile.tmp" # usage5h:0..100 表示"剩余"百分比(不是已用!);null = 未知/未拉到 # usage5hResetMs:距下次刷新的毫秒数;null = 未知 # todoProgress:0..100 完成百分比(cancelled 不计);null = 无 todo 列表 + # family:工具家族(shell/read/write/search/task/web/plan);null = 无状态色可用 $usageField = if ($null -ne $script:plan5hRemainingPct) { [int]$script:plan5hRemainingPct } else { $null } $resetField = if ($null -ne $script:plan5hResetMs) { [int]$script:plan5hResetMs } else { $null } $todoPct = if ($null -ne $script:plan5hTodoData) { [int]$script:plan5hTodoData.percent } else { $null } $todoCnt = if ($null -ne $script:plan5hTodoData) { ("{0}/{1}" -f $script:plan5hTodoData.completed, $script:plan5hTodoData.total) } else { $null } + $famField = if ($family) { [string]$family } else { $null } + # step / total / detail 是 agent(hook)推的 sub-step 状态,不是 detector + # 推的。detector 重写整个 payload 时,这里怎么处理取决于"为什么写"。 + # + # 两种写入语义完全不同(round-20 #5): + # + # - 元数据刷新(-KeepSubStep):每 60s 刷 5h 用量、每次刷 todo 进度。 + # 这两次的 state/message 是从当前 status.json 读回来原样再写回去的, + # 不代表"agent 进入了新的一步",所以必须把已有的 sub-step 合并回来, + # 否则正常使用中的进度会被静默清零(round-19 #2)。 + # + # - 状态推断(默认):detector 从 session log 推出一个新状态并写入。 + # 这条消息的语义是"agent 现在在干这个",它和上一条 hook 推的 + # sub-step 没有任何承接关系。无条件合并会让 pill 一直挂着上一步的 + # 计数:实测里 agent 早已换工具、甚至这一轮都结束了,pill 还卡在 + # "step 1/1" 显示一条早就跑完的 curl 命令,而且没有任何后续写入会 + # 把它清掉。所以状态推断一律重置。 + $stepField = -1 + $totalField = -1 + $detailField = '' + # `source` is the arbitration token, not a label (round-20 #7). The + # detector's settle logic reads `source == detector` as "this state is + # mine to rewrite". A metadata refresh restates the current state and + # refreshed numbers -- it does not claim the state -- so stamping + # `detector` on it silently hands write authority back to the detector. + # + # Observed consequence: a tool fails, the detector infers `error`, the + # PostToolUse hook pushes the same `error` (source=agent), then the 60s + # 5h-usage refresh stamps `detector` over it. From then on the detector + # owns the state and re-derives `error` from the same stale failed + # toolResult on every poll, clobbering the Stop hook's `done`. The pill + # sat on "Command failed" until the 60s no-event fallback finally + # dropped it to idle. + $sourceField = $S_DETECTOR + if ($KeepSubStep) { + $prev = Read-StatusObj + if ($prev) { + if ($prev.PSObject.Properties['step']) { $stepField = [int]$prev.step } + if ($prev.PSObject.Properties['total']) { $totalField = [int]$prev.total } + if ($prev.PSObject.Properties['detail'] -and $null -ne $prev.detail) { $detailField = [string]$prev.detail } + if ($prev.PSObject.Properties['source'] -and $prev.source) { $sourceField = [string]$prev.source } + } + } $payload = [PSCustomObject]@{ state = $state message = $message + family = $famField progress = -1 usage5h = $usageField usage5hResetMs = $resetField todoProgress = $todoPct todosCount = $todoCnt + step = $stepField + total = $totalField + detail = $detailField ts = (Get-Date).ToString($FMT_O) - source = $S_DETECTOR + source = $sourceField } | ConvertTo-Json -Compress [System.IO.File]::WriteAllText($tmp, $payload, [System.Text.Encoding]::UTF8) Move-Item -Path $tmp -Destination $statusFile -Force @@ -537,9 +857,12 @@ try { } # 3) 60s 无活动 → idle 兜底(每次循环都跑,不再被 mtime 缓存屏蔽) + # `waiting` 豁免:ask_user 弹出来之后,agent 就是在等用户,全程不会有 + # 新消息写入 session log。如果不豁免,60s 后它会被降级成"已静默 60s" + # 灰点——而"该回来了"这个信号恰好在用户离开最久的时候最需要保留。 if ($inferred -and $latestFile) { $curState = [string]$inferred.state - $isSettled = ($curState -eq $S_IDLE) -or ($curState -eq $S_ERROR) + $isSettled = ($curState -eq $S_IDLE) -or ($curState -eq $S_ERROR) -or ($curState -eq $S_WAITING) if (-not $isSettled) { $age = ($now - [System.IO.File]::GetLastWriteTime($latestFile)).TotalSeconds if ($age -gt 60) { @@ -557,7 +880,11 @@ try { $isOwn = ($curSource -eq $S_DETECTOR) $stateChanged = ($curState -ne $newState) - $isSettleNew = ($newState -eq $S_IDLE) -or ($newState -eq $S_ERROR) + # waiting 加入 settle 集合:ask_user 触发时,pre-tool-use hook 会先推一条 + # `working`,detector 随后才从 session log 推出 `waiting`。如果 waiting + # 不算 settle,detector 永远抢不回这条 status.json,pill 会一直卡在 + # "执行中"——正好毁掉这个状态存在的意义。 + $isSettleNew = ($newState -eq $S_IDLE) -or ($newState -eq $S_ERROR) -or ($newState -eq $S_WAITING) $shouldWrite = $false $reason = '' @@ -577,7 +904,7 @@ try { } if ($shouldWrite) { - Write-Status $inferred.state $inferred.message + Write-Status $inferred.state $inferred.message $inferred.family if ($script:lastDetectedState -ne $newState) { Log-Line "$newState :: $newMsg ($reason)" $script:lastDetectedState = $newState @@ -596,7 +923,9 @@ try { $curForUsage = Read-StatusObj $sForU = if ($curForUsage) { [string]$curForUsage.state } else { $S_IDLE } $mForU = if ($curForUsage) { [string]$curForUsage.message } else { '' } - Write-Status $sForU $mForU + $fForU = if ($curForUsage -and $curForUsage.PSObject.Properties['family']) { $curForUsage.family } else { $null } + # 元数据刷新:原样写回当前状态,必须保留 sub-step(-KeepSubStep) + Write-Status $sForU $mForU $fForU -KeepSubStep Log-Line ("5h usage refreshed: remaining=" + $curPct + "% resetMs=" + $curMs) } @@ -609,7 +938,9 @@ try { $curForTodo = Read-StatusObj $sForT = if ($curForTodo) { [string]$curForTodo.state } else { $S_IDLE } $mForT = if ($curForTodo) { [string]$curForTodo.message } else { '' } - Write-Status $sForT $mForT + $fForT = if ($curForTodo -and $curForTodo.PSObject.Properties['family']) { $curForTodo.family } else { $null } + # 元数据刷新:原样写回当前状态,必须保留 sub-step(-KeepSubStep) + Write-Status $sForT $mForT $fForT -KeepSubStep $script:plan5hLastWrittenTodoPct = $curTodoPct if ($null -ne $curTodoData) { Log-Line ("todo refreshed: " + $curTodoData.completed + "/" + $curTodoData.total + " = " + $curTodoPct + "%") diff --git a/plugins/antianqi/mcode-island/notify-island.ps1 b/plugins/antianqi/mcode-island/notify-island.ps1 index f20451e7..1b9f7d71 100644 --- a/plugins/antianqi/mcode-island/notify-island.ps1 +++ b/plugins/antianqi/mcode-island/notify-island.ps1 @@ -10,7 +10,10 @@ param( [ValidateSet('idle','thinking','working','waiting','done','error')] [string]$State = 'idle', [string]$Message = '', - [int]$Progress = -1 + [int]$Progress = -1, + [int]$Step = -1, + [int]$Total = -1, + [string]$Detail = '' ) $ErrorActionPreference = 'Stop' @@ -113,6 +116,9 @@ $payload = [PSCustomObject]@{ state = $State message = $Message progress = $Progress + step = $Step + total = $Total + detail = $Detail ts = $ts source = 'agent' } | ConvertTo-Json -Compress diff --git a/plugins/antianqi/mcode-island/plugin.json b/plugins/antianqi/mcode-island/plugin.json index f39491dc..cc3c26f8 100644 --- a/plugins/antianqi/mcode-island/plugin.json +++ b/plugins/antianqi/mcode-island/plugin.json @@ -1,8 +1,8 @@ { "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "mcode-island", - "version": "0.3.0", - "description": "Windows 桌面灵动岛 (Dynamic Island) 状态窗口:让 mcode agent 把工作状态(idle/thinking/working/waiting/done/error)实时推送到屏幕顶部悬浮 pill,agent 自己忙的时候用户不用切回 mcode 也能看到进度。v0.3.0 增加 io.minimax.mcode 客户端扩展(Hooks 草案),与 MiniMax-Code-Plugins PR #20 的 portable Hooks 提案对齐;mcode 0.2.4+ Runtime 触发,registry 接受后零改动生效。", + "version": "0.4.0", + "description": "Windows 桌面灵动岛 (Dynamic Island) 状态窗口:让 mcode agent 把工作状态(idle/thinking/working/waiting/done/error)实时推送到屏幕顶部悬浮 pill,agent 自己忙的时候用户不用切回 mcode 也能看到进度。v0.4.0 扩展 status.json schema 增加 step/total/detail 三个字段并更新 widget 渲染:agent 现在可以推送 'step 3/12 · fill username field' 这类 sub-step 进度,Computer Use 多步循环、长任务分阶段展示。Backward compat:所有新字段 optional。v0.3.0 增加 io.minimax.mcode 客户端扩展(Hooks 草案),与 MiniMax-Code-Plugins PR #20 的 portable Hooks 提案对齐;mcode 0.2.4+ Runtime 触发,registry 接受后零改动生效。", "author": { "name": "antianqi", "url": "https://github.com/antianqi" diff --git a/plugins/antianqi/mcode-island/scripts/lib/Get-5hUsage.ps1 b/plugins/antianqi/mcode-island/scripts/lib/Get-5hUsage.ps1 index cc312823..97412919 100644 --- a/plugins/antianqi/mcode-island/scripts/lib/Get-5hUsage.ps1 +++ b/plugins/antianqi/mcode-island/scripts/lib/Get-5hUsage.ps1 @@ -61,7 +61,7 @@ function _s { param([byte[]]$b) [System.Text.Encoding]::UTF8.GetString($b) } # 5h usage API endpoint. -$PLAN_API_HOST = _s (0x68,0x74,0x74,0x70,0x73,0x3A,0x2F,0x2F,0x61,0x70,0x69,0x2E,0x6D,0x69,0x6E,0x69,0x6D,0x61,0x78,0x69,0x2E,0x63,0x6F,0x6D) # https://api.minimax.com +$PLAN_API_HOST = _s (0x68,0x74,0x74,0x70,0x73,0x3A,0x2F,0x2F,0x61,0x70,0x69,0x2E,0x6D,0x69,0x6E,0x69,0x6D,0x61,0x78,0x69,0x2E,0x63,0x6F,0x6D) # https://api.minimaxi.com $PLAN_API_PATH = _s (0x2F,0x76,0x31,0x2F,0x63,0x6F,0x64,0x69,0x6E,0x67,0x5F,0x70,0x6C,0x61,0x6E,0x2F,0x72,0x65,0x6D,0x61,0x69,0x6E,0x73) # /v1/coding_plan/remains # Get-5hUsage diff --git a/plugins/antianqi/mcode-island/scripts/lib/Protect-Text.ps1 b/plugins/antianqi/mcode-island/scripts/lib/Protect-Text.ps1 new file mode 100644 index 00000000..1f5d8cbe --- /dev/null +++ b/plugins/antianqi/mcode-island/scripts/lib/Protect-Text.ps1 @@ -0,0 +1,103 @@ +# scripts/lib/Protect-Text.ps1 +# +# Redacts secret-shaped substrings from text that is about to be persisted +# or displayed. Exposes one function: +# +# Protect-SecretText [-Text] +# +# Returns the text with credential-shaped runs replaced by "". +# Returns $null for $null/empty input so callers can pass through blindly. +# +# # Why this exists +# +# Tool input reaches three sinks: +# +# 1. status.json -- polled by the widget every 400 ms +# 2. island.log -- append-only, was never size-capped +# 3. the pill -- always-on-top, so a screen share or a screenshot +# captures it +# +# The Computer Use branch of Format-ToolSummary was redacted in round-19 #1 +# because keystrokes can be passwords. The Bash branch was not: a command +# like `curl -H "Authorization: Bearer eyJ..."` or +# `export OPENAI_API_KEY=sk-...` was written to all three sinks verbatim, +# and the append-only log kept it indefinitely. +# +# # Why this is a lib and not a helper inside _lib.ps1 +# +# There are two independent producers of that text: +# +# 1. the hook path -- io.minimax.mcode/hooks/scripts/_lib.ps1 +# 2. the detector -- mcode-status-detect.ps1, which re-derives +# messages from mcode's own session log rather than +# from a hook event +# +# A helper wired into only one of them is a silent half-fix: the pill looks +# clean while the detector keeps writing the raw text to disk. Both +# dot-source this file. scripts/smoke.mjs check 9 asserts BOTH consumers +# actually invoke the function, not merely that it is defined. +# +# # Scope, deliberately +# +# This redacts credential-shaped runs, not whole commands. The command text +# on the pill is a documented feature (`Bash : npm test`), and the pill is +# the reason the plugin exists -- blanking every command would trade a real +# capability for a marginal privacy gain. What is not a feature is a bearer +# token sitting in a log for the next ten years, so that is what goes. +# +# This is a best-effort filter, not a DLP boundary. It is deliberately +# conservative: patterns require an unambiguous credential marker, so a +# false negative leaves the surrounding command readable rather than +# destroying useful signal. + +function Protect-SecretText { + [CmdletBinding()] + param( + [Parameter(Position = 0)] + [AllowNull()] + [AllowEmptyString()] + [string]$Text + ) + + if ([string]::IsNullOrEmpty($Text)) { return $Text } + + $out = $Text + + # 1. JSON Web Tokens. Run before the generic key=value pass, which + # would otherwise chew the `token=` half of a JWT query string and + # leave the payload segment readable. + $out = $out -replace '\beyJ[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{6,}(?:\.[A-Za-z0-9_\-]{6,})?', '' + + # 2. Authorization headers. Covers both the hyphenated header name and + # the RFC 6750 `Bearer` scheme, which is how the plugin's own 5h + # call authenticates. + $out = $out -replace '(?i)(authorization\s*[:=]\s*)["'']?\s*bearer\s+\S+', '$1Bearer ' + $out = $out -replace '(?i)\bbearer\s+[A-Za-z0-9._\-+/=]{12,}', 'Bearer ' + + # 3. Vendor-prefixed API keys (sk-..., sk-ant-..., ghp_..., xoxb-...). + # The length floor keeps ordinary prose containing "sk-" intact. + $out = $out -replace '\b(sk|sk-ant|ghp|gho|xoxb|xoxp)[-_][A-Za-z0-9_\-]{8,}', '$1-' + + # 4. Credential-shaped assignments, in the two forms that actually show + # up in tool input. The quoted-key (JSON) form is listed first: in + # `{"token":"..."}` the closing quote sits between the key and the + # colon, so a bare `key=value` pattern cannot reach it at all. + $secretKeyName = 'password|passwd|pwd|token|api[_-]?key|apikey|secret|access[_-]?key|client[_-]?secret|auth' + + # 4a. JSON: "token": "value" (prefix tolerated: "x-auth-token", "my_secret") + $out = $out -replace ('(?i)("[\w.-]*(?:' + $secretKeyName + ')"\s*:\s*)"[^"]*"'), '$1""' + $out = $out -replace ('(?i)("[\w.-]*(?:' + $secretKeyName + ')"\s*:\s*)''[^'']*'''), '$1''''' + + # 4b. Bare: token=value, password = value, secret:value. The value may + # be quoted, so each form gets its own pass to avoid a bare-value + # match swallowing a JSON object that follows. + $out = $out -replace ('(?i)\b(' + $secretKeyName + ')\b(\s*[:=]\s*)"[^"]*"'), '$1$2""' + $out = $out -replace ('(?i)\b(' + $secretKeyName + ')\b(\s*[:=]\s*)''[^'']*'''), '$1$2''''' + $out = $out -replace ('(?i)\b(' + $secretKeyName + ')\b(\s*[:=]\s*)[^\s,;)\]}]+'), '$1$2' + + # 5. Long CLI flag forms, where the secret is the following token + # rather than an `=value` pair. + $out = $out -replace '(?i)(--password|--passwd|--token|--api[-_]?key|--secret|--access[-_]?key|--client[-_]?secret)(\s+)\S+', '$1$2' + + return $out +} diff --git a/plugins/antianqi/mcode-island/scripts/smoke.mjs b/plugins/antianqi/mcode-island/scripts/smoke.mjs index e478922d..9f45fd1d 100644 --- a/plugins/antianqi/mcode-island/scripts/smoke.mjs +++ b/plugins/antianqi/mcode-island/scripts/smoke.mjs @@ -23,7 +23,7 @@ // 6. cross-platform: no hardcoded host-absolute paths, no // /Users/ or /home/ literals in any script or hooks.json entry -import { readFile, stat } from 'node:fs/promises'; +import { readFile, stat, readdir } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import { dirname, join, resolve, sep } from 'node:path'; @@ -155,7 +155,7 @@ const checkEntry = async (event, entry) => { }; const main = async () => { - console.log(`mcode-island v0.3.0 self-check`); + console.log(`mcode-island v0.4.0 self-check`); console.log(`plugin root: ${PLUGIN_ROOT}`); console.log('-'.repeat(60)); @@ -182,8 +182,8 @@ const main = async () => { } else { out('PASS', `plugin.json: name is "${plugin.name}"`); } - if (plugin.version !== '0.3.0') { - out('FAIL', `plugin.json: version is "${plugin.version}", expected "0.3.0"`); + if (plugin.version !== '0.4.0') { + out('FAIL', `plugin.json: version is "${plugin.version}", expected "0.4.0"`); } else { out('PASS', `plugin.json: version is "${plugin.version}"`); } @@ -338,6 +338,492 @@ const main = async () => { } } + // 5c1. Sub-step progress extension (round-13 refactor). + // notify-island.ps1 must accept -Step/-Total/-Detail and write them + // into status.json. mcode-island.ps1 widget must define + // Build-DisplayMessage and pass step/total/detail to it. + // _lib.ps1 Format-ToolSummary must extract mcode-computer-use + // action+coordinate; Push-Island must forward the new fields. + // The detailed functional tests live in test-substep-progress.mjs; + // here we lock the surface contract so a future refactor that + // drops the params surfaces in smoke (fast path) before reaching + // the slower pwsh-spawned tests. + const substepNotifyPath = join(PLUGIN_ROOT, 'notify-island.ps1'); + const substepWidgetPath = join(PLUGIN_ROOT, 'mcode-island.ps1'); + if (!(await exists(substepNotifyPath))) { + out('FAIL', 'notify-island.ps1 missing (sub-step lock skipped)'); + } else { + const notify = await readFile(substepNotifyPath, 'utf8'); + if (!/\[int\]\$Step\s*=\s*-1/.test(notify) || + !/\[int\]\$Total\s*=\s*-1/.test(notify) || + !/\[string\]\$Detail\s*=\s*''/.test(notify)) { + out('FAIL', 'notify-island.ps1: missing -Step/-Total/-Detail params'); + } else { + out('PASS', 'notify-island.ps1: declares -Step -Total -Detail'); + } + if (!/step\s*=\s*\$Step/.test(notify) || + !/total\s*=\s*\$Total/.test(notify) || + !/detail\s*=\s*\$Detail/.test(notify)) { + out('FAIL', 'notify-island.ps1: status.json payload missing step/total/detail fields'); + } else { + out('PASS', 'notify-island.ps1: writes step/total/detail to status.json'); + } + } + if (await exists(substepWidgetPath)) { + const widget = await readFile(substepWidgetPath, 'utf8'); + if (!/function Build-DisplayMessage/.test(widget)) { + out('FAIL', 'mcode-island.ps1: Build-DisplayMessage function missing'); + } else { + out('PASS', 'mcode-island.ps1: Build-DisplayMessage function present'); + } + if (!/\[int\]\$Step\s*=\s*-1/.test(widget) || + !/\[int\]\$Total\s*=\s*-1/.test(widget)) { + out('FAIL', 'mcode-island.ps1: Update-State missing Step/Total params'); + } else { + out('PASS', 'mcode-island.ps1: Update-State accepts Step/Total/Detail'); + } + } + const substepTestPath = join(PLUGIN_ROOT, 'scripts', 'test-substep-progress.mjs'); + if (!(await exists(substepTestPath))) { + out('WARN', 'scripts/test-substep-progress.mjs missing (sub-step detailed tests not run)'); + } else { + out('PASS', 'scripts/test-substep-progress.mjs exists (run separately for full suite)'); + } + + // 5c2. Click toggle extension (round-14 refactor). + // The pill's MouseLeftButtonUp must call Toggle-CallerWindow, NOT + // Focus-CallerWindow. Single-click show is one-way and forces the + // CLI to the front every time the user clicks, which is wrong for + // "I clicked the pill to hide the CLI" — the second click would + // re-show it and surprise the user. Toggle semantics match the + // user's "单击收起单击调出" mental model. + // Drift lock: Resolve-CallerWindow + Toggle-CallerWindow must + // exist as named functions (refactor target), and the click + // handler must invoke Toggle-CallerWindow, not Focus-CallerWindow. + if (await exists(substepWidgetPath)) { + const widget = await readFile(substepWidgetPath, 'utf8'); + if (!/function Resolve-CallerWindow\b/.test(widget)) { + out('FAIL', 'mcode-island.ps1: Resolve-CallerWindow function missing (toggle refactor target)'); + } else { + out('PASS', 'mcode-island.ps1: Resolve-CallerWindow function present'); + } + if (!/function Toggle-CallerWindow\b/.test(widget)) { + out('FAIL', 'mcode-island.ps1: Toggle-CallerWindow function missing'); + } else { + out('PASS', 'mcode-island.ps1: Toggle-CallerWindow function present'); + } + // Toggle-CallerWindow must dispatch on IsWindowVisible (the + // core visibility check). A regression that always calls + // ShowWindow(SW_HIDE) without checking state would silently + // break the toggle (every click = hide, never show). + if (!/IsWindowVisible\s*\(\s*\$r\.Hwnd\s*\)/.test(widget)) { + out('FAIL', 'mcode-island.ps1: Toggle-CallerWindow does not check IsWindowVisible'); + } else { + out('PASS', 'mcode-island.ps1: Toggle-CallerWindow gates on IsWindowVisible'); + } + // The click handler must call Toggle-CallerWindow, not Focus. + // We anchor on the MouseLeftButtonUp event to scope the check. + const clickMatch = widget.match(/Add_MouseLeftButtonUp\([\s\S]*?\}\s*\)\s*$/m); + if (!clickMatch) { + out('WARN', 'mcode-island.ps1: Add_MouseLeftButtonUp handler not found (drift lock skipped)'); + } else if (!/Toggle-CallerWindow\b/.test(clickMatch[0])) { + out('FAIL', 'mcode-island.ps1: MouseLeftButtonUp does not invoke Toggle-CallerWindow (still using Focus-only)'); + } else if (/Focus-CallerWindow\b/.test(clickMatch[0])) { + out('FAIL', 'mcode-island.ps1: MouseLeftButtonUp invokes both Toggle and Focus — pick one'); + } else { + out('PASS', 'mcode-island.ps1: MouseLeftButtonUp invokes Toggle-CallerWindow (single click toggles show/hide)'); + } + + // Round-15: Toggle's restore branch must call SW_MAXIMIZE (3), not + // SW_SHOW (5) / SW_RESTORE (9). SW_HIDE preserves the window's + // "non-maximized size"; if the WT window got accidentally resized + // to a thin strip (e.g., 480x84 from a snap gesture or our own + // mouse_event test artifacts), SW_SHOW / SW_RESTORE would re-show + // it as that strip — the user's complaint was "hide works, show is + // a thin strip". SW_MAXIMIZE forces full-screen on hidden / + // minimized / normal windows alike; no-op on already-maximized. + const toggleMatch = widget.match(/function Toggle-CallerWindow[\s\S]*?\n\}\n/); + if (!toggleMatch) { + out('WARN', 'mcode-island.ps1: Toggle-CallerWindow 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-CallerWindow else branch not parseable'); + } else if (!/ShowWindow\(\s*\$r\.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*\$r\.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.'); + } else { + out('PASS', 'mcode-island.ps1: Toggle restore branch forces SW_MAXIMIZE (full-screen on show, fixes 480x84 strip bug)'); + } + + // 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\(\$r\.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.'); + } else { + out('PASS', 'mcode-island.ps1: Toggle restore branch 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 + // (HWND_TOP, to push WT forward without foreground permission) + // must carry both SWP_NOSIZE and SWP_NOMOVE, and must NOT carry + // SWP_NOZORDER. + // + // SWP_NOSIZE cx=0/cy=0 is otherwise "resize to 0x0", triggering + // WT's min-size fallback to a 480x76 strip — the + // exact regression the user saw in round-17. + // SWP_NOMOVE X=0/Y=0 is otherwise "move to (0,0)". Invisible on + // a single primary monitor, but any secondary monitor + // whose origin is not 0 gets the restored window + // yanked to the primary's top-left corner + // (round-19 review #3). + // no SWP_NOZORDER that flag makes Windows ignore + // hWndInsertAfter entirely, so passing HWND_TOP + // alongside it is self-defeating: the whole point of + // this call is the z-order change. + // + // The round-17 version of this lock asserted the literal + // `SWP_NOZORDER -bor SWP_NOSIZE`, which is exactly the pair the + // review asked to change, so it had to be rewritten rather than + // updated. Match on the flag names, not their order. + if (!/SWP_NOSIZE\s*=\s*0x0001/.test(widget)) { + out('FAIL', 'mcode-island.ps1: WinAPI class missing SWP_NOSIZE constant (0x0001).'); + } else if (!/SWP_NOMOVE\s*=\s*0x0002/.test(widget)) { + out('FAIL', 'mcode-island.ps1: WinAPI class missing SWP_NOMOVE constant (0x0002).'); + } else { + // Pull the flags expression that feeds the HWND_TOP call. It + // is assigned just ABOVE the call, not inside it, so anchor on + // the assignment and look for the HWND_TOP call within the + // same statement rather than scanning forward from the call. + const flagsExpr = (toggleBody.match(/\$nofollow\s*=\s*([^\n\r]+)/) || [null, ''])[1]; + const hasZorderCall = /SetWindowPos\([^)]*HWND_TOP/.test(toggleBody); + + const problems = []; + if (!hasZorderCall) { + problems.push('the HWND_TOP SetWindowPos call is gone, so the window is never pushed forward'); + } + if (!/SWP_NOSIZE/.test(flagsExpr)) { + problems.push('SWP_NOSIZE (cx=0/cy=0 would resize WT to 0x0 and trigger its 480x76 min-size fallback)'); + } + if (!/SWP_NOMOVE/.test(flagsExpr)) { + problems.push('SWP_NOMOVE (X=0/Y=0 would move the window to (0,0) on any monitor whose origin is not 0)'); + } + if (/SWP_NOZORDER/.test(flagsExpr)) { + problems.push('SWP_NOZORDER is present, which makes Windows ignore hWndInsertAfter and defeats the HWND_TOP z-order call'); + } + + if (problems.length > 0) { + for (const p of problems) { + out('FAIL', `mcode-island.ps1: Toggle z-order SetWindowPos(HWND_TOP) — ${p}`); + } + } else { + out('PASS', 'mcode-island.ps1: Toggle z-order SetWindowPos carries SWP_NOSIZE + SWP_NOMOVE and omits SWP_NOZORDER (size preserved, position preserved, z-order actually applied)'); + } + } + } + } + + // 5d. Drift lock: the round-18 tool-verb table. The pill shows a + // present-tense verb ("Running") while a tool is in flight and a + // past-tense one ("Ran") once it returns, mirroring the mcode CLI TUI + // descriptor table (launcher-GHPADSKI.js HL[]). Before this the pill + // showed the bare tool name for every phase, so "working" and "done" + // were indistinguishable at a glance. + // + // These locks are deliberately text-shaped rather than behavioral: a + // behavioral test would need a real mcode session log. What we can + // cheaply guarantee is that (a) the table exists, (b) it covers the + // tools the detector actually infers, and (c) all three Infer-State + // branches route through the verb lookup instead of hardcoding names. + const detectPath = join(PLUGIN_ROOT, 'mcode-status-detect.ps1'); + if (!(await exists(detectPath))) { + out('FAIL', 'mcode-status-detect.ps1 missing (tool-verb drift lock skipped)'); + } else { + const detect = await readFile(detectPath, 'utf8'); + + // (a) table + helpers present + for (const [label, re] of [ + ['$TOOL_ACTIONS table', /\$TOOL_ACTIONS\s*=\s*@\{/], + ['$TOOL_FAMILIES table', /\$TOOL_FAMILIES\s*=\s*@\{/], + ['Get-ToolKey helper', /function Get-ToolKey\s*\(/], + ['Get-ToolVerb helper', /function Get-ToolVerb\s*\(/], + ['Get-ToolVerbFallback helper', /function Get-ToolVerbFallback\s*\(/], + ['Get-ToolFamily helper', /function Get-ToolFamily\s*\(/], + ]) { + if (re.test(detect)) { + out('PASS', `mcode-status-detect.ps1: ${label} present`); + } else { + out('FAIL', `mcode-status-detect.ps1: ${label} missing (round-18 tool-verb contract broken)`); + } + } + + // (b) the table covers the tools users actually see. Each entry must + // carry all three phases; a partial entry would render an empty + // message and the pill would silently go blank for that tool. + const actionsMatch = detect.match(/\$TOOL_ACTIONS\s*=\s*@\{([\s\S]*?)\n\}/); + if (!actionsMatch) { + out('FAIL', 'mcode-status-detect.ps1: cannot slice $TOOL_ACTIONS body'); + } else { + const body = actionsMatch[1]; + const entryRe = /'([a-z0-9_]+)'\s*=\s*@\{\s*running\s*=\s*'([^']*)'\s*;\s*done\s*=\s*'([^']*)'\s*;\s*fail\s*=\s*'([^']*)'\s*\}/g; + const found = new Map(); + let m; + while ((m = entryRe.exec(body)) !== null) { + found.set(m[1], { running: m[2], done: m[3], fail: m[4] }); + } + out('PASS', `mcode-status-detect.ps1: $TOOL_ACTIONS has ${found.size} entries with all 3 phases`); + + for (const required of ['bash', 'read', 'write', 'edit', 'grep', 'glob', 'task']) { + if (found.has(required)) { + out('PASS', `$TOOL_ACTIONS covers "${required}"`); + } else { + out('FAIL', `$TOOL_ACTIONS is missing "${required}" (the pill would show a bare name for it)`); + } + } + // Every mapped family must resolve to a family, otherwise the + // widget's $familyMap lookup silently falls back to the state + // color and the round-18 tinting never happens. + const famMatch = detect.match(/\$TOOL_FAMILIES\s*=\s*@\{([\s\S]*?)\n\}/); + if (!famMatch) { + out('FAIL', 'mcode-status-detect.ps1: cannot slice $TOOL_FAMILIES body'); + } else { + for (const [key, fam] of Object.entries({ + bash: 'shell', read: 'read', edit: 'write', write: 'write', + grep: 'search', glob: 'search', task: 'task', + task_output: 'task', ask_user: 'task', + web_search: 'web', web_fetch: 'web', todowrite: 'plan', + })) { + // ask_user is intentionally excluded from this list. It was + // mapped to the 'task' family in round-18 and is deliberately + // unmapped in 5d2, where it reports `waiting` instead. + if (key === 'ask_user') continue; + const re = new RegExp(`'${key}'\\s*=\\s*'${fam}'`); + if (re.test(famMatch[1])) { + out('PASS', `$TOOL_FAMILIES maps "${key}" -> "${fam}"`); + } else { + out('FAIL', `$TOOL_FAMILIES does not map "${key}" -> "${fam}" (widget tinting will not fire for it)`); + } + } + } + } + + // (c) all three Infer-State branches must route through the verb + // lookup. This is the actual regression: reverting any one branch to + // `"$($m.toolName) $MSG_OK"` would still pass a "table exists" check + // while the pill went back to showing a bare name. + // + // There are TWO sites per state (the ledger.jsonl branch and the + // messages.jsonl branch), so a presence test is not enough -- breaking + // only the ledger branch leaves the messages branch matching and the + // check stays green. These locks assert a minimum occurrence count so + // that reverting EITHER site goes red. + for (const [label, re, min] of [ + ['WORKING branch uses Get-ToolVerb', /state=\$S_WORKING;\s*message="\$verb\s/g, 2], + ['DONE branch uses Get-ToolVerb', /state=\$S_DONE;\s*message=\$verb/g, 2], + ['ERROR branch uses Get-ToolVerb', /state=\$S_ERROR;\s*message=\$verb/g, 2], + ]) { + const hits = detect.match(re); + const n = hits ? hits.length : 0; + if (n >= min) { + out('PASS', `mcode-status-detect.ps1: ${label} (${n}/${min} sites)`); + } else { + out('FAIL', `mcode-status-detect.ps1: ${label} -- only ${n}/${min} sites use the verb lookup; a branch was reverted to a hardcoded tool name`); + } + } + + // (d) family must be threaded to disk, or the widget can never tint. + if (/family\s*=\s*\$famField/.test(detect)) { + out('PASS', 'mcode-status-detect.ps1: Write-Status emits the family field'); + } else { + out('FAIL', 'mcode-status-detect.ps1: Write-Status does not emit `family` (widget tinting is dead code)'); + } + + // 5d2. Drift lock: ask_user must report `waiting`, not `working`. + // + // ask_user is the only tool whose "in flight" state means the agent is + // BLOCKED on the user rather than busy. Showing it as `working` tells + // the user "leave it alone, it is making progress", which is the + // opposite of the truth and defeats the entire point of the state. + // This matters most on `full access` setups, where the permission hook + // never fires and ask_user is the only thing that ever blocks. + for (const [label, re] of [ + ['$S_WAITING is defined', /\$S_WAITING\s*=\s*_s\s*\(/], + ['Test-IsAskUser helper', /function Test-IsAskUser\s*\(/], + ['Get-AskQuestionCount helper', /function Get-AskQuestionCount\s*\(/], + ]) { + if (re.test(detect)) { + out('PASS', `mcode-status-detect.ps1: ${label} present`); + } else { + out('FAIL', `mcode-status-detect.ps1: ${label} missing (ask_user cannot report waiting)`); + } + } + + // Both tool paths (ledger.jsonl and messages.jsonl) must route + // ask_user to waiting. A presence test is not enough here for the same + // reason as the verb locks above: there are two sites, and breaking one + // leaves the other matching. + const askSites = detect.match(/state=\$S_WAITING;\s*message="Asking \$n question/g) || []; + if (askSites.length >= 2) { + out('PASS', `mcode-status-detect.ps1: both ask_user tool paths report waiting (${askSites.length}/2 sites)`); + } else { + out('FAIL', `mcode-status-detect.ps1: only ${askSites.length}/2 ask_user paths report waiting; a path still treats a blocked agent as busy`); + } + + // $args is a PowerShell automatic variable. Naming a function parameter + // $args shadows it, and every read inside the function returns the + // function's own argument list instead of the caller's value -- which + // here silently produced a question count of 0 for every input. Lock + // the parameter name so the regression cannot come back unnoticed. + if (/function Get-AskQuestionCount\(\$toolArgs\)/.test(detect)) { + out('PASS', 'mcode-status-detect.ps1: Get-AskQuestionCount avoids the $args automatic variable'); + } else { + out('FAIL', 'mcode-status-detect.ps1: Get-AskQuestionCount takes $args, which shadows the PowerShell automatic variable and always yields a count of 0'); + } + + // waiting must be in BOTH settle sets. The 60s no-activity fallback + // would otherwise downgrade a pending questionnaire to "已静默 60s" + // exactly when the user has been away longest, and the takeover + // arbitration would let a hook-pushed `working` win forever. + for (const [label, re] of [ + ['60s idle fallback exempts waiting', /\$isSettled\s*=.*-or\s*\(\$curState\s+-eq\s+\$S_WAITING\)/], + ['takeover arbitration treats waiting as settle', /\$isSettleNew\s*=.*-or\s*\(\$newState\s+-eq\s+\$S_WAITING\)/], + ]) { + if (re.test(detect)) { + out('PASS', `mcode-status-detect.ps1: ${label}`); + } else { + out('FAIL', `mcode-status-detect.ps1: ${label} (a pending ask_user would be downgraded or shadowed)`); + } + } + + // ask_user must NOT carry a family tint: it wears the waiting state + // color, and painting it the task cyan would look like an in-flight + // delegation -- the confusion this change exists to remove. + if (!/'ask_user'\s*=\s*'task'/.test(detect)) { + out('PASS', 'mcode-status-detect.ps1: ask_user carries no family tint (waits in the state color)'); + } else { + out('FAIL', "mcode-status-detect.ps1: ask_user is still mapped to the 'task' family, so a blocked agent wears the delegation color"); + } + } + + // 5e. Drift lock: the widget must consume `family` and apply it ONLY to + // active states. Tinting `done`/`error` by family would destroy the + // green=success / red=failure signal the user relies on. + const widgetPath = join(PLUGIN_ROOT, 'mcode-island.ps1'); + if (!(await exists(widgetPath))) { + out('FAIL', 'mcode-island.ps1 missing (family-tint drift lock skipped)'); + } else { + const widget = await readFile(widgetPath, 'utf8'); + // Hoisted out of the if/else below: the hue-separation check further + // down needs the same slice, and a block-scoped const would be out of + // scope by then. + const famBlock = widget.match(/\$familyMap\s*=\s*@\{([\s\S]*?)\n\}/); + if (!famBlock) { + out('FAIL', 'mcode-island.ps1: $familyMap table missing'); + } else { + for (const fam of ['shell', 'read', 'write', 'search', 'task', 'web', 'plan']) { + if (new RegExp(`^\\s*${fam}\\s*=`, 'm').test(famBlock[1])) { + out('PASS', `$familyMap covers "${fam}"`); + } else { + out('FAIL', `$familyMap does not cover "${fam}"`); + } + } + } + if (/\[string\]\$Family\s*=\s*''/.test(widget)) { + out('PASS', 'mcode-island.ps1: Update-State takes a $Family parameter'); + } else { + out('FAIL', 'mcode-island.ps1: Update-State has no $Family parameter'); + } + // The gate must be checked on the *family tint* line specifically. + // `$State -in @('thinking','working','waiting')` also appears on the + // pulse / elapsed / progress branches, so a bare substring test stays + // green after the tint is ungated -- a false green this section exists + // to prevent. Anchor on the `$Family -and $State` conjunction instead. + if (/\$Family\s+-and\s+\$State\s+-in\s+@\('thinking','working','waiting'\)/.test(widget)) { + out('PASS', 'mcode-island.ps1: family tint is gated to active states (done/error keep their result color)'); + } else { + out('FAIL', 'mcode-island.ps1: family tint is not gated to active states (done/error would lose their green/red result signal)'); + } + if (/\$script:statusDot\.Fill\s*=\s*C\s+\$dotHex/.test(widget) + && /\$script:pulseRing\.Fill\s*=\s*C\s+\$ringHex/.test(widget)) { + out('PASS', 'mcode-island.ps1: dot/ring are painted from the resolved color (family-aware)'); + } else { + out('FAIL', 'mcode-island.ps1: dot/ring still read $s.dot directly, bypassing family tinting'); + } + + // Perceptual separation. Two families landing on near-identical colors + // are indistinguishable on the pill, which defeats the point of + // tinting. Measured with CIE76 dE in CIELAB, NOT raw luminance: + // luminance alone calls blue and purple "identical" (0.02 apart) even + // though they are plainly different hues, so a luminance threshold + // just produces false alarms. dE < 20 is the usual "not the same color + // to a human eye" cutoff for flat UI fills. + const famColors = new Map(); + // No `^` anchor: with the `m` flag a leading `\s*` is free to swallow + // the preceding newline and match mid-line, and with a greedy + // [\s\S]* body it can also skip the first entry. A `g`-only scan with + // a `[ \t]*` (not `\s*`) indent keeps one match per table row. + // Colors in the table are 8-digit #AARRGGBB (the alpha byte is FF), + // so the pattern has to be {8} or the closing quote never lines up. + const colorRe = /[ \t]*([a-z]+)[ \t]*=[ \t]*'(#[0-9A-Fa-f]{8})'/g; + let cm; + while ((cm = colorRe.exec(famBlock[1])) !== null) { + famColors.set(cm[1], cm[2].slice(3).toUpperCase()); // drop #FF alpha + } + if (famColors.size < 7) { + out('FAIL', `$familyMap: parsed only ${famColors.size}/7 family colors; the separation check below would be vacuous`); + } else { + out('PASS', `$familyMap: parsed all ${famColors.size} family colors`); + } + // sRGB -> XYZ (D65) -> CIELAB + const toLab = (hex) => { + const ch = [0, 2, 4].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255); + const lin = ch.map((v) => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4)); + const [r, g, b] = lin; + const X = (0.4124564 * r + 0.3575761 * g + 0.1804375 * b) / 0.95047; + const Y = (0.2126729 * r + 0.7151522 * g + 0.0721750 * b); + const Z = (0.0193339 * r + 0.1191920 * g + 0.9503041 * b) / 1.08883; + const f = (t) => (t > 0.008856 ? Math.cbrt(t) : 7.787 * t + 16 / 116); + const [fx, fy, fz] = [f(X), f(Y), f(Z)]; + return [116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)]; + }; + const deltaE = (a, b) => { + const [la, aa, ba] = toLab(a); + const [lb, ab, bb] = toLab(b); + return Math.hypot(la - lb, aa - ab, ba - bb); + }; + const MIN_DE = 20; + const keys = [...famColors.keys()]; + let tooClose = 0; + for (let i = 0; i < keys.length; i++) { + for (let j = i + 1; j < keys.length; j++) { + const d = deltaE(famColors.get(keys[i]), famColors.get(keys[j])); + if (d < MIN_DE) { + out('FAIL', `$familyMap colors "${keys[i]}" (#${famColors.get(keys[i])}) and "${keys[j]}" (#${famColors.get(keys[j])}) are only dE ${d.toFixed(1)} apart (< ${MIN_DE}); they read as the same color on the pill`); + tooClose++; + } + } + } + if (tooClose === 0) { + out('PASS', `$familyMap: all ${keys.length} family colors are perceptually distinct (min pairwise CIELAB dE >= ${MIN_DE})`); + } + } + // 5b. Drift lock: permission-request.ps1 must emit `{"decision":"ask"}`, // not `allow` or `deny`. The 0.2.4 Runtime default for PermissionRequest // is fail-closed; an observer Hook that returns `allow` or `deny` @@ -442,6 +928,279 @@ const main = async () => { if (bad === 0) out('PASS', 'Get-5hUsage.ps1: no hardcoded host paths'); } + // 7. 5h endpoint disclosure consistency (round-20). + // + // Contract: every MiniMax API host the plugin NAMES in user-facing + // docs (and in the lib's own trailing comment) must be the SAME host + // the code actually calls. The code obfuscates the URL into a byte + // array as a PS 5.1 parser-quirk defense, so this check DECODES that + // array and compares the docs against the decoded truth. It never + // hard-codes the endpoint itself, which matters twice over: the check + // cannot drift into becoming a second copy of the disclosure it is + // meant to police, and it cannot be satisfied by "fixing" the docs to + // match whatever the code happens to be tomorrow. + // + // This exists because the two drifted. The README carried an absolute + // security claim ("no request is ever sent to a host other than + // api.minimax.io") that was simply false -- the code never called + // that host. A disclosure that overstates isolation is worse than no + // disclosure at all. + const planLibAbs = join(PLUGIN_ROOT, 'scripts', 'lib', 'Get-5hUsage.ps1'); + if (await exists(planLibAbs)) { + const libText = await readFile(planLibAbs, 'utf8'); + const hostArr = libText.match(/\$PLAN_API_HOST\s*=\s*_s\s*\(([^)]*)\)/); + if (!hostArr) { + out('FAIL', 'Get-5hUsage.ps1: cannot locate the $PLAN_API_HOST byte array (disclosure check has no truth to compare against)'); + } else { + const bytes = hostArr[1] + .split(',') + .map(s => parseInt(s.trim().replace(/^0x/i, ''), 16)) + .filter(n => Number.isFinite(n)); + const realUrl = Buffer.from(bytes).toString('utf8'); + let realHost = null; + try { realHost = new URL(realUrl).hostname; } catch { realHost = null; } + if (!realHost) { + out('FAIL', `Get-5hUsage.ps1: decoded $PLAN_API_HOST is not a parseable URL ("${realUrl}")`); + } else { + out('PASS', `Get-5hUsage.ps1: $PLAN_API_HOST decodes to a valid host (${realHost})`); + + const namedHosts = [ + 'README.md', + join('skills', 'SKILL.md'), + join('skills', 'mcode-island', 'SKILL.md'), + join('scripts', 'lib', 'Get-5hUsage.ps1'), + ]; + let drift = 0, named = 0; + for (const rel of namedHosts) { + const abs = join(PLUGIN_ROOT, rel); + if (!(await exists(abs))) continue; + const text = await readFile(abs, 'utf8'); + // Fresh regex per line: no shared lastIndex state. + for (const [i, line] of text.split(/\r?\n/).entries()) { + for (const hit of line.match(/api\.minimax[a-z]*\.(?:com|io|ai|cn)/gi) || []) { + named++; + if (hit.toLowerCase() !== realHost.toLowerCase()) { + out('FAIL', `${rel}:${i + 1}: names "${hit}" but the code calls "${realHost}"`); + drift++; + } + } + } + } + if (drift === 0) { + out('PASS', `5h disclosure: all ${named} host mention(s) match the real endpoint (${realHost})`); + } + } + } + } else { + out('FAIL', 'scripts/lib/Get-5hUsage.ps1 missing (disclosure check skipped)'); + } + + // 8. Log growth is bounded (round-20). + // + // Contract: the two append-only logs under %APPDATA%\mcode-island\ + // must cap their own size. mcode-island.ps1 line 7 has documented + // "keep only the last 1KB" since the widget was written, but Dbg was + // a bare Add-Content and never implemented it -- widget.log reached + // 37 MB in 38 days (~3.6 MB/day of POLL lines during active use), and + // nothing ever trimmed it. A stated cap that no code enforces is a + // comment, not a guarantee, so this check asserts the enforcement + // rather than trusting the prose. + for (const t of [ + { rel: 'mcode-island.ps1', fn: 'Dbg' }, + { rel: 'mcode-status-detect.ps1', fn: 'Log-Line' }, + ]) { + const abs = join(PLUGIN_ROOT, t.rel); + if (!(await exists(abs))) { + out('FAIL', `${t.rel} missing (log-cap check skipped)`); + continue; + } + const text = await readFile(abs, 'utf8'); + const body = text.match(new RegExp(`function\\s+${t.fn}\\s*\\([^)]*\\)\\s*\\{([\\s\\S]*?)\\n\\}`)); + if (!body) { + out('FAIL', `${t.rel}: function ${t.fn} not found (cannot verify the log cap)`); + continue; + } + const src = body[1]; + const capped = /Length/i.test(src) && + /(gt|ge|gt\s)/i.test(src) && + /(SetLength|WriteAllText|Truncate|Get-Content|Rotate|-Tail)/i.test(src); + if (!capped) { + out('FAIL', `${t.rel}: ${t.fn} appends with no size cap (log grows without bound)`); + } else { + out('PASS', `${t.rel}: ${t.fn} bounds its own log size`); + } + } + + // 9. Secret-shaped command text is redacted before it is persisted + // (round-20). + // + // Contract: tool-input text (Bash commands, Computer Use keystrokes) + // is written to status.json, to the append-only logs, and rendered on + // an always-on-top pill. The Computer Use branch of Format-ToolSummary + // was redacted in round-19 #1; the Bash branch was not, so a command + // carrying a bearer token or an API key landed on disk verbatim. + // + // There are two independent producers of that text -- the hook path + // (_lib.ps1) and the detector, which re-derives messages from mcode's + // own session log -- so a helper wired into only one of them is a + // silent half-fix. The helper therefore lives in a shared lib, and + // this check asserts BOTH consumers route through it. Asserting the + // helper merely *exists* is the false-green shape: dead code passes it. + const protectLibAbs = join(PLUGIN_ROOT, 'scripts', 'lib', 'Protect-Text.ps1'); + if (!(await exists(protectLibAbs))) { + out('FAIL', 'scripts/lib/Protect-Text.ps1 missing (command secrets are persisted verbatim)'); + } else { + const protectLib = await readFile(protectLibAbs, 'utf8'); + const helper = protectLib.match(/function\s+(Protect-SecretText|Redact-SecretText)/); + if (!helper) { + out('FAIL', 'scripts/lib/Protect-Text.ps1: Protect-SecretText not defined'); + } else { + out('PASS', `scripts/lib/Protect-Text.ps1: ${helper[1]} defined`); + + for (const consumer of [ + { rel: join('io.minimax.mcode', 'hooks', 'scripts', '_lib.ps1'), label: 'hook path' }, + { rel: 'mcode-status-detect.ps1', label: 'detector' }, + ]) { + const abs = join(PLUGIN_ROOT, consumer.rel); + if (!(await exists(abs))) { + out('FAIL', `${consumer.rel} missing (${consumer.label} redaction not verified)`); + continue; + } + const text = await readFile(abs, 'utf8'); + if (!/Protect-Text\.ps1/.test(text)) { + out('FAIL', `${consumer.rel}: does not dot-source scripts/lib/Protect-Text.ps1`); + continue; + } + // The helper is DEFINED in the other file, so every + // occurrence of its name in this consumer is an actual + // invocation -- the dot-source line names the .ps1 file, + // not the function. A consumer that dot-sources but never + // calls therefore scores 0 and fails here, which is the + // whole point of the check. + const uses = (text.match(new RegExp(helper[1], 'g')) || []).length; + if (uses < 1) { + out('FAIL', `${consumer.rel}: dot-sources Protect-Text.ps1 but never calls ${helper[1]} (${consumer.label} is unredacted)`); + } else { + out('PASS', `${consumer.rel}: ${consumer.label} calls ${helper[1]} (${uses} site(s))`); + } + } + } + } + + // 10. Hook invocations survive an install path containing spaces + // (round-20). + // + // Contract: a plugin can legitimately be installed under a path with + // spaces or shell metacharacters, and mcode 0.5.4 explicitly fixed + // its own managed updater for exactly that case. ${PLUGIN_ROOT} is + // substituted at runtime, so the only way to break a spaced path is + // to hand the shell a pre-joined string instead of an argv array. + // This asserts the array form survives -- a "command" that embeds the + // path in a quoted shell string, or an args entry that is itself a + // joined string with embedded quotes, is exactly the regression. + const hooksJsonAbs = join(PLUGIN_ROOT, 'io.minimax.mcode', 'hooks', 'hooks.json'); + if (!(await exists(hooksJsonAbs))) { + out('FAIL', 'io.minimax.mcode/hooks/hooks.json missing (space-path check skipped)'); + } else { + let parsed = null; + try { + parsed = JSON.parse(await readFile(hooksJsonAbs, 'utf8')); + } catch (e) { + out('FAIL', `io.minimax.mcode/hooks/hooks.json: not parseable as JSON (${e.message})`); + } + if (parsed) { + let entries = 0, bad = 0; + for (const [event, list] of Object.entries(parsed.hooks || {})) { + for (const h of (Array.isArray(list) ? list : [list])) { + if (!h || typeof h !== 'object') continue; + entries++; + const cmd = typeof h.command === 'string' ? h.command : ''; + // A command that inlines the plugin root has been + // flattened into a shell string; ${PLUGIN_ROOT} must + // arrive as its own argv element. + if (cmd.includes('${PLUGIN_ROOT}')) { + out('FAIL', `${event}: command embeds ${PLUGIN_ROOT} ("${cmd}") - breaks on install paths with spaces`); + bad++; + } + if (!Array.isArray(h.args)) { + out('FAIL', `${event}: args is not an array (${JSON.stringify(h.args)}) - a joined string is not space-safe`); + bad++; + } else { + for (const a of h.args) { + if (typeof a === 'string' && /["']\s*\S+\s+.*\s*["']/.test(a)) { + out('FAIL', `${event}: args entry looks pre-quoted/joined ("${a}") - breaks on spaced paths`); + bad++; + } + } + } + } + } + if (bad === 0 && entries > 0) { + out('PASS', `hooks.json: all ${entries} hook entr(ies) use argv arrays (space-safe)`); + } + } + } + + // 11. Non-ASCII .ps1 files carry a UTF-8 BOM (round-20 #4). + // + // Contract: every hook and every script the plugin spawns with + // `powershell` (Windows PowerShell 5.1) must survive 5.1's parser. + // 5.1 decodes a BOM-less script using the system ANSI codepage, so a + // file containing non-ASCII comments is mis-decoded and the C# + // here-string in the detector stops being a here-string -- `using + // System;` is then parsed as PowerShell and the script dies before + // its first statement. + // + // .gitattributes pins `*.ps1 text eol=lf`, and its comment claims + // "LF avoids both failure modes". That is backwards: bare LF is part + // of the problem, and the guarantee is what propagates it. Either CRLF + // or a UTF-8 BOM fixes the parse (both verified against 5.1), but + // flipping .gitattributes to CRLF would re-break the Linux-side + // validator the same comment is trying to protect. The BOM is the + // narrower fix: it leaves the line-ending policy alone and is just + // three bytes at the head of the file. + // + // This was not hypothetical. Syncing the committed tree into the + // install directory via the documented `Copy-Item -Recurse -Force` + // flow installed a detector that could not start under 5.1 -- the + // only reason the running copy worked is that it had a BOM that the + // repository does not carry. + const ps1Files = []; + const walk = async (dir) => { + let entries; + try { entries = await readdir(dir, { withFileTypes: true }); } catch { return; } + for (const ent of entries) { + const abs = join(dir, ent.name); + if (ent.isDirectory()) { + if (ent.name === 'node_modules' || ent.name === '.git') continue; + await walk(abs); + } else if (ent.name.endsWith('.ps1')) { + ps1Files.push(abs); + } + } + }; + await walk(PLUGIN_ROOT); + + let missingBom = 0, nonAsciiCount = 0; + for (const abs of ps1Files) { + const buf = await readFile(abs); + const rel = abs.slice(PLUGIN_ROOT.length + 1); + const hasBom = buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf; + let nonAscii = false; + for (let i = 0; i < buf.length; i++) { + if (buf[i] > 0x7f) { nonAscii = true; break; } + } + if (!nonAscii) continue; // ASCII-only is safe without a BOM + nonAsciiCount++; + if (!hasBom) { + out('FAIL', `${rel}: contains non-ASCII but has no UTF-8 BOM (Windows PowerShell 5.1 cannot parse it)`); + missingBom++; + } + } + if (missingBom === 0) { + out('PASS', `ps1 encoding: all ${nonAsciiCount} non-ASCII .ps1 file(s) carry a UTF-8 BOM (${ps1Files.length} scanned)`); + } + finish(); }; diff --git a/plugins/antianqi/mcode-island/scripts/test-substep-progress.mjs b/plugins/antianqi/mcode-island/scripts/test-substep-progress.mjs new file mode 100644 index 00000000..dc8fd475 --- /dev/null +++ b/plugins/antianqi/mcode-island/scripts/test-substep-progress.mjs @@ -0,0 +1,639 @@ +// test-substep-progress.mjs — behavioral tests for the sub-step progress +// contract and the summary redaction rules. +// +// These are behavioral on purpose. The round-19 review (hetaoBackend) called +// out that the previous suite was "text-shaped": it asserted that a source +// file contained a given substring, so it could not tell a working +// implementation from a deleted code path wrapped in a comment. Every check +// below runs the real code. +// +// node scripts/test-substep-progress.mjs +// +// Exit 0 = all passed. Any failure exits 1. + +import { readFileSync, existsSync, mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { execFileSync } from 'node:child_process'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const PLUGIN_ROOT = dirname(HERE); + +let pass = 0; +let fail = 0; +const failures = []; + +function ok(name) { pass++; console.log(`[ok ] ${name}`); } +function bad(name, why) { + fail++; + failures.push(`${name}: ${why}`); + console.log(`[FAIL] ${name}\n ${why}`); +} +function check(name, cond, why) { cond ? ok(name) : bad(name, why); } +function eq(name, actual, expected) { + const a = JSON.stringify(actual); + const e = JSON.stringify(expected); + a === e ? ok(name) : bad(name, `expected ${e}, got ${a}`); +} + +// --------------------------------------------------------------------------- +// PowerShell harness +// --------------------------------------------------------------------------- + +const PS = process.env.PS_BIN || (process.platform === 'win32' ? 'powershell.exe' : 'pwsh'); + +// A user-specific path is not a test result. Fall back to PATH the way a CI +// runner would, and skip loudly rather than silently passing. +function resolvePs() { + if (PS.includes('\\') || PS.includes('/')) { + if (existsSync(PS)) return PS; + } + for (const candidate of ['pwsh', 'powershell.exe', 'powershell']) { + try { + execFileSync(candidate, ['-NoProfile', '-Command', '$PSVersionTable.PSVersion.Major'], + { stdio: 'ignore' }); + return candidate; + } catch { /* try the next one */ } + } + return null; +} + +const PS_BIN = resolvePs(); + +// Scratch dir for the behavioral checks. Kept out of the repo so a failed run +// cannot leave fixtures behind for the next one to read. +const TMP = mkdtempSync(join(tmpdir(), 'island-substep-')); + +function runPs(script) { + 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 + // A1 A4 and every UTF-8 decoder turns it into a replacement char -- + // which fails assertions that are actually correct. Setting + // [Console]::OutputEncoding makes the child emit UTF-8 regardless of what + // the console is set to. The BOM matters for the other direction: without + // 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'] }); + let buf = out; + if (buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf) { + buf = buf.subarray(3); + } + return buf.toString('utf8'); +} + +/** Extract a top-level `function Name(...) { ... }` block from a PowerShell file. */ +function extractFn(src, name) { + // PowerShell allows both `function Name {` and `function Name($a) {`, and + // this repo uses the bare form. Requiring the paren made every lookup miss. + const start = src.search(new RegExp(`^function ${name}\\s*(\\(|\\{)`, 'm')); + if (start < 0) throw new Error(`function ${name} not found`); + const rest = src.slice(start); + // walk braces from the first one after the signature + const open = rest.indexOf('{'); + if (open < 0) throw new Error(`no opening brace in ${name}`); + let depth = 0; + for (let i = open; i < rest.length; i++) { + if (rest[i] === '{') depth++; + else if (rest[i] === '}') { + depth--; + if (depth === 0) return rest.slice(0, i + 1); + } + } + throw new Error(`unbalanced braces in ${name}`); +} + +/** Extract a `@{ ... }` hashtable assignment that starts at `from` lines in. */ +function extractTable(lines, startLine) { + const out = []; + for (let i = startLine; i < lines.length; i++) { + out.push(lines[i]); + if (/^\}/.test(lines[i])) return out.join('\n'); + } + throw new Error(`unterminated table at line ${startLine}`); +} + +function findLine(lines, re) { + const i = lines.findIndex((l) => re.test(l)); + if (i < 0) throw new Error(`pattern not found: ${re}`); + return i + 1; +} + +// --------------------------------------------------------------------------- +// 1. Secret redaction (round-19 review #1) +// --------------------------------------------------------------------------- + +// The canary is the whole point of this section. A test that only checks the +// source no longer contains `tool_input.text` would pass against an +// implementation that redacts by accident, or one where the redaction was +// replaced by a different leak. Plant a recognizable secret, run the real +// Format-ToolSummary, and assert the secret is absent from the output. +const CANARY = 'SUPER-SECRET-PASSWORD-8f3a91c2'; + +if (!PS_BIN) { + bad('PowerShell available', 'no pwsh/powershell on PATH; behavioral checks cannot run'); +} else { + const libPath = join(PLUGIN_ROOT, 'io.minimax.mcode', 'hooks', 'scripts', '_lib.ps1'); + const lib = readFileSync(libPath, 'utf8'); + + // Format-ToolSummary calls Protect-SecretText, which lives in a + // separate lib (round-20). Extracting the function body alone would + // leave the call unresolvable and the harness would die on an + // undefined command rather than on a real assertion -- so the harness + // dot-sources the lib, exactly as _lib.ps1 does in production. + const protectLibPath = join(PLUGIN_ROOT, 'scripts', 'lib', 'Protect-Text.ps1'); + + let formatToolSummary; + try { + formatToolSummary = extractFn(lib, 'Format-ToolSummary'); + } catch (e) { + bad('Format-ToolSummary is extractable', e.message); + } + + if (formatToolSummary) { + ok('Format-ToolSummary is extractable'); + + const harness = ` +$ErrorActionPreference = 'Stop' +. '${protectLibPath.replace(/'/g, "''")}' +${formatToolSummary} +$evt = [PSCustomObject]@{ + tool_name = 'mcode-computer-use' + tool_input = [PSCustomObject]@{ action = 'type'; coordinate = $null; text = '${CANARY}' } +} +Format-ToolSummary $evt +`; + const out = runPs(harness); + + // The secret must not appear anywhere in the output. + check('typed text is redacted: secret absent from summary', + !out.includes(CANARY), + `secret leaked into Format-ToolSummary output: ${JSON.stringify(out.trim())}`); + + // And the pill must still answer the useful question: is it typing? + check('typed text is redacted: action still reported', + /type/.test(out), + `expected the action name to survive redaction, got ${JSON.stringify(out.trim())}`); + + // Length is a safe signal to keep: it says nothing about content. + check('typed text is redacted: length is reported instead', + new RegExp(String(CANARY.length)).test(out) && /redacted/i.test(out), + `expected "<${CANARY.length} chars, redacted>"-style output, got ${JSON.stringify(out.trim())}`); + + // Negative control: a non-secret coordinate path must still work, or + // the redaction could have been implemented by disabling the branch. + const coordHarness = ` +$ErrorActionPreference = 'Stop' +. '${protectLibPath.replace(/'/g, "''")}' +${formatToolSummary} +$evt = [PSCustomObject]@{ + tool_name = 'mcode-computer-use' + tool_input = [PSCustomObject]@{ action = 'click'; coordinate = @(1024,768); text = $null } +} +Format-ToolSummary $evt +`; + const coordOut = runPs(coordHarness); + check('coordinate path is unaffected by redaction', + /1024,768/.test(coordOut), + `expected the coordinate to render as (1024,768), got ${JSON.stringify(coordOut.trim())}`); + } +} + +// --------------------------------------------------------------------------- +// 2. Sub-step rendering (round-19 review #4) +// --------------------------------------------------------------------------- + +const widgetPath = join(PLUGIN_ROOT, 'mcode-island.ps1'); +const widget = readFileSync(widgetPath, 'utf8'); +let buildDisplayMessage; +try { + buildDisplayMessage = extractFn(widget, 'Build-DisplayMessage'); + ok('Build-DisplayMessage is extractable'); +} catch (e) { + bad('Build-DisplayMessage is extractable', e.message); +} + +if (buildDisplayMessage && PS_BIN) { + const cases = [ + // [name, Message, Step, Total, Detail, expected] + ['step with total and detail renders "step 1/1 · detail"', + 'bash ok', 1, 1, 'ls -la /tmp', 'step 1/1 · ls -la /tmp'], + ['step without total renders "step 3 · detail"', + 'bash ok', 3, -1, 'build project', 'step 3 · build project'], + ['step with empty detail renders "step 3/12" only', + 'bash ok', 3, 12, '', 'step 3/12'], + ['no step falls back to the message (detail is NOT shown)', + 'bash ok', -1, -1, 'ls -la /tmp', 'bash ok'], + ['no step and no message renders empty', + '', -1, -1, 'ls -la', ''], + ]; + + for (const [name, msg, step, total, detail, expected] of cases) { + const harness = ` +$ErrorActionPreference = 'Stop' +${buildDisplayMessage} +Build-DisplayMessage -Message '${msg.replace(/'/g, "''")}' -Step ${step} -Total ${total} -Detail '${detail.replace(/'/g, "''")}' +`; + const out = runPs(harness).trim(); + eq(name, out, expected); + } +} + +// The review's #4 was that post-tool-use passes -Detail but no -Step, so +// Build-DisplayMessage's Step<=0 branch discards it. Prove the two sides +// actually connect: BOTH Push-Island calls must pass a Step, and the renderer +// must use it. +// +// Counting matters here. A single `[\s\S]*?` scan across the file passes as +// soon as ONE call carries -Step, so removing it from just the error branch +// left the check green -- a false green the negative-injection pass caught. +// Each call is matched on its own line and both must pass. +{ + const postTool = readFileSync( + join(PLUGIN_ROOT, 'io.minimax.mcode', 'hooks', 'scripts', 'post-tool-use.ps1'), 'utf8'); + + const calls = postTool.split('\n') + .map((l, i) => ({ line: l, n: i + 1 })) + .filter((x) => /^\s*Push-Island\b/.test(x.line)); + + check('post-tool-use has both Push-Island calls (error + done)', + calls.length === 2, + `expected 2 Push-Island calls, found ${calls.length}; a new branch was added without the sub-step arguments`); + + for (const call of calls) { + const label = /-State\s+error/.test(call.line) ? 'error' : 'done'; + check(`post-tool-use ${label} call passes -Step`, + /-Step\s+\$step\b/.test(call.line), + `the ${label} branch (line ${call.n}) calls Push-Island with -Detail but no -Step, so Build-DisplayMessage discards the detail`); + check(`post-tool-use ${label} call passes -Total`, + /-Total\s+\$total\b/.test(call.line), + `the ${label} branch (line ${call.n}) does not pass -Total, so the pill cannot render "step N/M"`); + } + + // Passing -Step is necessary but not sufficient. If the local $step/$total + // are ever set to the "unset" sentinel (-1, which is what both + // notify-island.ps1 and Build-DisplayMessage treat as absent), the flag + // check above still passes while the detail is discarded again at render + // time. A negative-injection pass caught exactly this: setting + // `$step = -1` left every assertion green. + const stepAssign = postTool.match(/^\$step\s*=\s*(-?\d+)\s*$/m); + const totalAssign = postTool.match(/^\$total\s*=\s*(-?\d+)\s*$/m); + + check('post-tool-use assigns a positive $step', + !!stepAssign && Number(stepAssign[1]) > 0, + stepAssign + ? `$step is ${stepAssign[1]}, which is the "unset" sentinel; the pill will discard the detail at render time` + : 'post-tool-use.ps1 never assigns $step, so -Step $step forwards an undefined value'); + + check('post-tool-use assigns a positive $total', + !!totalAssign && Number(totalAssign[1]) > 0, + totalAssign + ? `$total is ${totalAssign[1]}, which is the "unset" sentinel; the pill cannot render "step N/M"` + : 'post-tool-use.ps1 never assigns $total, so -Total $total forwards an undefined value'); +} + +// --------------------------------------------------------------------------- +// 3. Detector preserves sub-step fields across a rewrite (review #2) +// --------------------------------------------------------------------------- + +// Read-StatusObj + the field-preservation block in Write-Status. A behavioral +// check: start from a status.json carrying sub-step fields, run the real +// Write-Status payload construction, and confirm the fields survive. +if (PS_BIN) { + const detectPath = join(PLUGIN_ROOT, 'mcode-status-detect.ps1'); + const detect = readFileSync(detectPath, 'utf8'); + const detectLines = detect.split('\n'); + + const writeStatusFn = extractFn(detect, 'Write-Status'); + + // Extract the sub-step decision block as a standalone repro of the + // logic, so the test does not need the detector's whole environment. + // + // The block is bounded by two stable statements rather than by the + // internal ordering of the read-modify-write: round-20 #5 wrapped it + // in `if ($KeepSubStep) { ... }`, so a regex anchored on `$prev` + // appearing before `$detailField` silently stopped matching. Anchor on + // the initialisers and the payload construction instead, and drive the + // switch explicitly -- this section pins the PRESERVE half; section 5 + // pins the reset half and the call-site wiring. + const preserveMatch = writeStatusFn.match(/\$stepField\s*=\s*-1[\s\S]*?\$payload\s*=/); + + if (!preserveMatch) { + bad('Write-Status preserves sub-step fields', + 'could not find the step/total/detail decision block in Write-Status'); + } else { + ok('Write-Status preserves sub-step fields'); + + const harness = ` +$ErrorActionPreference = 'Stop' +$statusFile = $env:ISLAND_TEST_STATUS +$KeepSubStep = $true +function Read-StatusObj { + if (!(Test-Path $statusFile)) { return $null } + try { return ([System.IO.File]::ReadAllText($statusFile) | ConvertFrom-Json) } catch { return $null } +} +${preserveMatch[0].replace(/\$payload\s*=$/, '')} +"$stepField|$totalField|$detailField" +`; + const statusFile = join(TMP, 'status.json'); + writeFileSync(statusFile, JSON.stringify({ + state: 'working', message: 'bash', family: 'shell', + step: 4, total: 12, detail: 'running tests', source: 'hook', + }), 'utf8'); + + const out = runPs( + `$env:ISLAND_TEST_STATUS = '${statusFile.replace(/'/g, "''")}'\n` + harness).trim(); + eq('detector rewrite preserves step/total/detail', out, '4|12|running tests'); + + // And the no-prior case must still default sanely, otherwise a fresh + // install would render "step -1/-1". + writeFileSync(statusFile, JSON.stringify({ state: 'idle', message: '', source: 'detector' }), 'utf8'); + const out2 = runPs( + `$env:ISLAND_TEST_STATUS = '${statusFile.replace(/'/g, "''")}'\n` + harness).trim(); + eq('detector rewrite defaults sub-step to unset', out2, '-1|-1|'); + } +} + +// --------------------------------------------------------------------------- +// 4. SWP_NOMOVE on the restore path (review #3) +// --------------------------------------------------------------------------- + +{ + const toggle = extractFn(widget, 'Toggle-CallerWindow'); + const zorderCall = toggle.match( + /SetWindowPos\([^)]*HWND_TOP[^)]*\)/); + + check('restore z-order call exists', !!zorderCall, + 'could not find the HWND_TOP SetWindowPos call in Toggle-CallerWindow'); + + if (zorderCall) { + // Pull the flags expression that feeds this specific call. Anchor on + // the assignment and require the HWND_TOP call to be the very next + // SetWindowPos: a broad `[\s\S]*` scan would happily pick up flags + // belonging to the earlier work-area call and pass regardless. + const flagsExpr = (toggle.match(/\$nofollow\s*=\s*([^\n\r]+)/) || [null, ''])[1]; + check('restore z-order flags include SWP_NOMOVE', + /SWP_NOMOVE/.test(flagsExpr), + 'the restore path omits SWP_NOMOVE, so X=0/Y=0 moves the window to (0,0) on any monitor whose origin is not 0'); + + check('restore z-order flags include SWP_NOSIZE', + /SWP_NOSIZE/.test(flagsExpr), + 'the restore path omits SWP_NOSIZE, so cx=0/cy=0 resizes the window to 0x0'); + + check('restore z-order flags do NOT include SWP_NOZORDER', + !/SWP_NOZORDER/.test(flagsExpr), + 'the restore path passes HWND_TOP together with SWP_NOZORDER, which makes Windows ignore hWndInsertAfter and defeats the z-order call'); + } +} + +// --------------------------------------------------------------------------- +// 5. A detector state-inference write must CLEAR a stale sub-step +// (round-20 #5) +// --------------------------------------------------------------------------- +// +// Section 3 pins the other half of the same contract: a metadata refresh +// must PRESERVE step/total/detail, because the 60s 5h-usage rewrite and +// the todo rewrite restate the current state rather than announcing a new +// step. Preserving unconditionally is only half right. +// +// A detector state-inference write is different in kind. It read a new +// tool call out of the session log and is asserting "the agent is doing +// THIS now". That message has no relationship to whatever sub-step a hook +// pushed last, so inheriting step/total/detail leaves the previous turn's +// counter on the pill indefinitely. Observed live: the pill sat on +// "step 1/1" showing a curl command from a finished tool call, through +// several subsequent turns, because nothing ever cleared it. +// +// The two write paths must therefore be distinguished explicitly, not by +// guessing from the message text. +{ + const detectPath = join(PLUGIN_ROOT, 'mcode-status-detect.ps1'); + const detect = readFileSync(detectPath, 'utf8'); + const writeStatusFn = extractFn(detect, 'Write-Status'); + + const hasSwitch = /function\s+Write-Status[\s\S]*?\[switch\]\$KeepSubStep/.test(writeStatusFn); + check('Write-Status exposes a -KeepSubStep switch', + hasSwitch, + 'state-inference and metadata-refresh writes are not distinguished, so a stale sub-step can never be cleared'); + + // Anchor on the two stable statements either side of the decision so + // the extraction survives reformatting inside the block. + const block = writeStatusFn.match(/\$stepField\s*=\s*-1[\s\S]*?\$payload\s*=/); + + if (!hasSwitch || !block) { + bad('detector state write clears a stale sub-step', + 'could not extract the sub-step decision block from Write-Status'); + } else { + ok('detector state write clears a stale sub-step'); + + const harness = ` +$ErrorActionPreference = 'Stop' +$statusFile = $env:ISLAND_TEST_STATUS +$KeepSubStep = [bool]::Parse($env:ISLAND_TEST_KEEP) +# $S_DETECTOR is a script-scope constant in the detector; the extracted +# block needs it to resolve the claim-ownership path. +$S_DETECTOR = 'detector' +function Read-StatusObj { + if (!(Test-Path $statusFile)) { return $null } + try { return ([System.IO.File]::ReadAllText($statusFile) | ConvertFrom-Json) } catch { return $null } +} +${block[0].replace(/\$payload\s*=$/, '')} +"$stepField|$totalField|$detailField|$sourceField" +`; + const statusFile = join(TMP, 'status.json'); + const run = (keep) => runPs( + `$env:ISLAND_TEST_STATUS = '${statusFile.replace(/'/g, "''")}'\n` + + `$env:ISLAND_TEST_KEEP = '${keep ? 'True' : 'False'}'\n` + harness).trim(); + + // Previous status carries a finished sub-step from an agent push. + const stale = { + state: 'done', message: 'Bash ok', family: 'shell', + step: 1, total: 1, detail: 'curl -H "Authorization: Bearer "', + source: 'agent', + }; + + writeFileSync(statusFile, JSON.stringify(stale), 'utf8'); + eq('state-inference write clears the stale sub-step', run(false).split('|').slice(0, 3).join('|'), '-1|-1|'); + + writeFileSync(statusFile, JSON.stringify(stale), 'utf8'); + eq('metadata-refresh write (-KeepSubStep) still preserves it', + run(true).split('|').slice(0, 3).join('|'), '1|1|curl -H "Authorization: Bearer "'); + + // round-20 #7: a metadata refresh must not change who owns the + // state. `source` is the arbitration token -- the detector treats + // `source == detector` as "mine to rewrite" -- so a 5h-usage + // refresh that stamps `detector` onto an agent-owned state + // silently hands write authority back to the detector. The + // detector then re-derives `error` from the same stale failed + // toolResult on every poll and clobbers the Stop hook's `done`. + // That is the observed "pill stopped on Command failed". + writeFileSync(statusFile, JSON.stringify(stale), 'utf8'); + eq('metadata-refresh write preserves the previous owner', + run(true).split('|')[3], 'agent'); + + writeFileSync(statusFile, JSON.stringify(stale), 'utf8'); + eq('state-inference write claims ownership', + run(false).split('|')[3], 'detector'); + } + + // The call sites must actually differ. A switch nobody passes is the + // dead-code shape this suite exists to catch. + const callSites = detect.split('\n') + .map((l, i) => ({ l, n: i + 1 })) + .filter(({ l }) => /^\s*Write-Status\s+\$/.test(l)); + const keeps = callSites.filter(({ l }) => /-KeepSubStep/.test(l)); + const infers = callSites.filter(({ l }) => /\$inferred\.state/.test(l)); + + eq('Write-Status has 3 call sites', callSites.length, 3); + check('the state-inference call site does NOT pass -KeepSubStep', + infers.length === 1 && !/-KeepSubStep/.test(infers[0].l), + `inference call site must clear stale sub-step (line ${infers[0]?.n})`); + check('the 5h-usage and todo call sites DO pass -KeepSubStep', + keeps.length === 2, + `expected 2 -KeepSubStep call sites, found ${keeps.length}`); +} + +// --------------------------------------------------------------------------- +// 6. The pill never renders raw tool JSON (round-20 #6) +// --------------------------------------------------------------------------- +// +// The detector's `running` message used to be built as +// "$verb " + (ConvertTo-Json $args -Compress) truncated to 60 chars +// which put this on screen: +// +// Running {"command":"$ErrorActionPreference=\u0027Continue\u0027\n... +// +// Escaped quotes, a JSON key, and a truncation that can land mid-token. +// `skill` is not in $TOOL_ACTIONS / $TOOL_FAMILIES, so it always took that +// JSON path -- meaning one of the most frequent tools on screen was the +// least readable. +// +// The contract: a human-readable field, never the serialised argument +// object. Unknown tools resolve to the verb alone rather than dumping +// JSON, because "Using" is a fine pill and "Using {\"name\":...}" is not. +{ + const detectPath = join(PLUGIN_ROOT, 'mcode-status-detect.ps1'); + const detect = readFileSync(detectPath, 'utf8'); + + let formatArgs; + try { + formatArgs = extractFn(detect, 'Format-ToolArgs'); + } catch (e) { + bad('Format-ToolArgs is extractable', e.message); + } + + if (!formatArgs) { + bad('tool summaries are human-readable, not raw JSON', + 'Format-ToolArgs not found: the detector still renders ConvertTo-Json output'); + } else { + ok('Format-ToolArgs is extractable'); + + const protectLibPath = join(PLUGIN_ROOT, 'scripts', 'lib', 'Protect-Text.ps1'); + const harness = ` +$ErrorActionPreference = 'Stop' +. '${protectLibPath.replace(/'/g, "''")}' +${formatArgs} +$cases = ConvertFrom-Json $env:ISLAND_TEST_CASES +foreach ($c in $cases) { + # $c.a is already a PSCustomObject -- the outer ConvertFrom-Json turned + # the nested object into one. Re-parsing it fails on the '@'. + $r = Format-ToolArgs $c.t $c.a + '{0}={1}' -f $c.n, $r +} +`; + + const cases = [ + { n: 'bash', t: 'bash', a: { command: 'npm test' }, want: 'npm test' }, + // round-20 #8: a leading `$var = ...` preamble is agent boilerplate, + // not the work. The first *executed* line is what the user wants + // to read on the pill. + { n: 'bashMulti', t: 'bash', a: { command: "$x = 'Stop'\nGet-ChildItem" }, want: 'Get-ChildItem' }, + // Every line an assignment: nothing to fall through to, so the + // first line must still render rather than the pill going blank. + { n: 'bashAllVar', t: 'bash', a: { command: "$a = 1\n$b = 2" }, want: '$a = 1' }, + // Comments are boilerplate too. + { n: 'bashComment', t: 'bash', a: { command: "# setup\nnpm run build" }, want: 'npm run build' }, + { n: 'read', t: 'read', a: { file_path: 'C:\\proj\\a\\file.ts' }, want: 'file.ts' }, + { n: 'write', t: 'write', a: { file_path: 'C:\\proj\\a\\out.json' }, want: 'out.json' }, + { n: 'edit', t: 'edit', a: { file_path: 'C:\\proj\\a\\x.ps1' }, want: 'x.ps1' }, + { n: 'grep', t: 'grep', a: { pattern: 'TODO' }, want: 'TODO' }, + { n: 'glob', t: 'glob', a: { pattern: '**/*.ps1' }, want: '*.ps1' }, + { n: 'websearch', t: 'web_search', a: { query: 'mcode changelog' }, want: 'mcode changelog' }, + { n: 'webfetch', t: 'web_fetch', a: { url: 'https://example.com/y' }, want: 'example.com' }, + // skill is absent from the tool taxonomy: this is the case + // that was guaranteed to render as raw JSON. + { n: 'skill', t: 'skill', a: { name: 'docx' }, want: 'docx' }, + { n: 'taskout', t: 'task_output', a: { task_id: 'bg_abc123' }, want: 'bg_abc123' }, + // Unknown tool with an unrecognised shape: verb-only, not JSON. + { n: 'unknown', t: 'wibble', a: { zzz: 1 }, want: '' }, + // Credential in a command must still be redacted on this path. + { n: 'secret', t: 'bash', a: { command: "curl -H 'Authorization: Bearer eyJhbGciOi.SUPERSECRET'" } }, + ]; + + const out = runPs( + `$env:ISLAND_TEST_CASES = '${JSON.stringify(cases).replace(/'/g, "''")}'\n` + harness); + const got = {}; + for (const line of out.split(/\r?\n/)) { + const m = line.match(/^(\w+)=(.*)$/); + if (m) got[m[1]] = m[2]; + } + + for (const c of cases) { + if (!(c.n in got)) { + bad(`summary ${c.n}`, `no output produced (got ${JSON.stringify(out.trim())})`); + continue; + } + const v = got[c.n]; + // The blanket rule, asserted for every case including the + // secret one: nothing JSON-shaped reaches the pill. + const jsonish = /\{\s*"|":\s*"|\\u00[0-9a-f]{2}/i.test(v); + if (jsonish) { + bad(`summary ${c.n}`, `renders raw JSON: ${JSON.stringify(v)}`); + continue; + } + if (c.want === '') { + // Unknown tool with nothing worth showing: must be empty, + // not merely "not JSON". A partial dump is still a dump. + if (v !== '') { + bad(`summary ${c.n}`, `expected empty, got ${JSON.stringify(v)}`); + continue; + } + } else if (c.want !== undefined) { + if (!v.toLowerCase().includes(String(c.want).toLowerCase())) { + bad(`summary ${c.n}`, `expected to contain ${JSON.stringify(c.want)}, got ${JSON.stringify(v)}`); + continue; + } + } + if (c.n === 'secret' && /SUPERSECRET/.test(v)) { + bad('summary secret', `credential survived: ${JSON.stringify(v)}`); + continue; + } + ok(`summary ${c.n}${c.want !== undefined ? ` -> ${JSON.stringify(v)}` : ' (redacted)'}`); + } + + // A multi-line command must not spill its body onto a one-line pill, + // and the boilerplate preamble must not be what the user reads. + if ('bashMulti' in got && /\$x/.test(got.bashMulti)) { + bad('summary bashMulti', 'the variable-assignment preamble was shown instead of the first executed line'); + } else if ('bashMulti' in got) { + ok('summary bashMulti skips the assignment preamble'); + } + } +} + +// --------------------------------------------------------------------------- +// summary +// --------------------------------------------------------------------------- + +try { rmSync(TMP, { recursive: true, force: true }); } catch { /* best effort */ } + +console.log('-'.repeat(60)); +console.log(`${pass} pass, ${fail} fail`); +if (fail > 0) { + console.log('\nfailures:'); + for (const f of failures) console.log(` - ${f}`); + process.exit(1); +} diff --git a/plugins/antianqi/mcode-island/scripts/test-windows-workflow-local.ps1 b/plugins/antianqi/mcode-island/scripts/test-windows-workflow-local.ps1 index f4ab6496..3674d695 100644 --- a/plugins/antianqi/mcode-island/scripts/test-windows-workflow-local.ps1 +++ b/plugins/antianqi/mcode-island/scripts/test-windows-workflow-local.ps1 @@ -1,4 +1,4 @@ -# test-windows-workflow-local.ps1 +# test-windows-workflow-local.ps1 # # Local runner that mirrors `.github/workflows/mcode-island-windows.yml` # 1:1 on a Windows host. Use this when: diff --git a/plugins/antianqi/mcode-island/set-token.ps1 b/plugins/antianqi/mcode-island/set-token.ps1 index 2e28b579..2771d3f4 100644 --- a/plugins/antianqi/mcode-island/set-token.ps1 +++ b/plugins/antianqi/mcode-island/set-token.ps1 @@ -1,4 +1,4 @@ -# mcode-island - 设置 5h 用量 API token +# mcode-island - 设置 5h 用量 API token # 用法: # set-token.ps1 # 写 token 到 %APPDATA%\mcode-island\config.json # set-token.ps1 -Show # 显示当前是否已配置 diff --git a/plugins/antianqi/mcode-island/skills/SKILL.md b/plugins/antianqi/mcode-island/skills/SKILL.md index 6ab1c982..949428e1 100644 --- a/plugins/antianqi/mcode-island/skills/SKILL.md +++ b/plugins/antianqi/mcode-island/skills/SKILL.md @@ -180,7 +180,7 @@ All widget state lives under `%APPDATA%\mcode-island\`: No data leaves the local machine *unless* an opt-in 5-hour usage token is configured. See the **Network access** + **Accounts** sections in -`README.md` for the exact host (`api.minimax.io/v1/coding_plan/remains`), +`README.md` for the exact host (`api.minimaxi.com/v1/coding_plan/remains`), the rate limit (one GET per 60 s), and the storage locations (`config.json:planApiToken` or env `MINIMAX_OAUTH_TOKEN` / `MINIMAX_API_KEY`). When no token is configured the plugin makes no network requests at all. diff --git a/plugins/antianqi/mcode-island/skills/mcode-island/SKILL.md b/plugins/antianqi/mcode-island/skills/mcode-island/SKILL.md index 6ab1c982..0d1a8c40 100644 --- a/plugins/antianqi/mcode-island/skills/mcode-island/SKILL.md +++ b/plugins/antianqi/mcode-island/skills/mcode-island/SKILL.md @@ -130,6 +130,33 @@ $plugin = "" # directory that contains notify-island.ps1 & "$plugin\notify-island.ps1" -State waiting -Message "permission prompt" ``` +For sub-step progress (Computer Use iterative loops, multi-step plans, +long-running tool sequences) push `-Step` / `-Total` / `-Detail` so the pill +shows what the agent is doing *right now* instead of only the coarse state: + +```powershell +& "$plugin\notify-island.ps1" -State working -Message "Computer Use" ` + -Step 3 -Total 12 -Detail "fill username field" +# → pill renders: "step 3/12 · fill username field" + +& "$plugin\notify-island.ps1" -State working -Message "Bash" ` + -Step 5 -Detail "npm install" +# → pill renders: "step 5 · npm install" (total omitted → no "/N") + +& "$plugin\notify-island.ps1" -State done -Message "Bash ok" +# → pill renders: "Bash ok" (no step → legacy behavior, fully backward compat) +``` + +Semantics: +- `-Step` is 1-based; omit (or pass `-1`) to keep the coarse state-only display. +- `-Total` is optional; pass `-1` or omit when the iteration count is unknown. +- `-Detail` is free text. When present, it replaces `Message` in the pill + ("step 3/12 · detail") to avoid stacking ("Bash ok · fill username"). When + absent, only the step number renders. + +All three params are optional and the schema is backward compatible — old +callers that omit them see no behavior change. + `` is the directory that contains `notify-island.ps1`. Substitute the absolute path your user installed the plugin at. The Skill body deliberately avoids hard-coded paths so any user / any install location works. @@ -180,7 +207,7 @@ All widget state lives under `%APPDATA%\mcode-island\`: No data leaves the local machine *unless* an opt-in 5-hour usage token is configured. See the **Network access** + **Accounts** sections in -`README.md` for the exact host (`api.minimax.io/v1/coding_plan/remains`), +`README.md` for the exact host (`api.minimaxi.com/v1/coding_plan/remains`), the rate limit (one GET per 60 s), and the storage locations (`config.json:planApiToken` or env `MINIMAX_OAUTH_TOKEN` / `MINIMAX_API_KEY`). When no token is configured the plugin makes no network requests at all.