From ca395b68e1dc78ee567fc759820f3089076bd710 Mon Sep 17 00:00:00 2001 From: antianqi Date: Wed, 23 Sep 2026 17:33:12 +0800 Subject: [PATCH 01/38] feat(mcode-island): add sub-step progress fields to status.json (v0.4.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends status.json schema with three optional fields — step, total, detail — so agents can publish per-iteration progress during Computer Use loops, multi-step plans, and long-running tool sequences. The widget now renders "step N[/M] · detail" instead of only the coarse state, so the user can see what the agent is doing right now without waiting for it to finish. notify-island.ps1 New params: -Step , -Total , -Detail All default to -1 / -1 / "" for full backward compatibility. The status.json payload now writes step/total/detail alongside the existing state/message/progress fields. Old callers (omitting the new params) produce identical status.json behavior except for three extra fields whose values are the sentinels. mcode-island.ps1 (widget) New Build-DisplayMessage helper that converts the schema fields into the visible pill text. Render branches: step > 0 + total > 0 -> "step N/M · " step > 0 + total <= 0 -> "step N · " step > 0 + detail == "" -> "step N[/M]" (avoid message stacking) step <= 0 -> original message (legacy path) The poll-handler signature and init block were extended with the same three fields and the change-detection string now includes them, so consecutive working+message pushes with different step values are not collapsed by the 400 ms dedupe. io.minimax.mcode/hooks/scripts/_lib.ps1 Push-Island now accepts -Step/-Total/-Detail and forwards them to notify-island.ps1. Format-ToolSummary has a new mcode-computer-use branch that extracts action + coordinate with explicit -join "," so coordinate arrays render as "(x,y)" not PowerShell's default "(x y)" (the latter looked like a truncated number on the pill). io.minimax.mcode/hooks/scripts/post-tool-use.ps1 Pushes -Detail with the Format-ToolSummary output split so the pill shows "Bash ok · ls -la /tmp" instead of "Bash ok". pre-tool-use.ps1 already used Format-ToolSummary so no change there. All new schema fields are optional. notify-island.ps1 callers that omit -Step/-Total/-Detail see no behavior change. Widget versions that do not know the new fields ignore them (PSObject.Properties[name] everywhere). Verified by smoke.mjs case "backward compat: old callers produce step=-1, total=-1, detail=""" and test-substep-progress.mjs case "Push-Island backward compat: missing new params -> step=-1, total=-1, detail=""". no credentials : none added; the IPC is local-filesystem only no network : no network calls added; notify-island.ps1 still writes status.json under %APPDATA%/mcode-island no telemetry : no telemetry added; the existing append-only island.log is unchanged and the 400 ms polling cadence is unchanged no third-party svcs : no new third-party deps; the change is pure PowerShell + schema cross-platform : no hardcoded host paths; no /Users/ /home/ C:\ /mnt/ literals introduced; the existing smoke.mjs cross-platform scan still passes atomic write : notify-island.ps1 already writes status.json atomically via staging + rename; no change closed schema : status.json is open by design (not contract- locked), but each new field has a documented sentinel (-1 / -1 / "") so absent fields are semantically equivalent to explicit sentinels smoke self-check : smoke.mjs gained 5 new checks under section 5c1; locked the new surface contract so a future refactor that drops the params surfaces in smoke before reaching the slower pwsh-spawned tests smoke.mjs : 48 pass, 7 warn, 0 fail (7 warn are pre-existing "forward" event catalog entries pending mcode 0.2.4+ Runtime confirmation; unchanged by this PR) scripts/test-substep-progress.mjs : 26 pass, 0 fail Sections: 1. notify-island.ps1 schema round-trip (4 cases) - all three new fields round-trip with explicit values - step without total: step=5, total stays -1 - backward compat: step=-1, total=-1, detail="" - existing fields (state/message/progress/ts/source) preserved 2. Build-DisplayMessage function contract (9 cases) covering include step values, total omission, detail omission, empty message, total=0 edge, step=-1 with orphan detail 3. Format-ToolSummary for mcode-computer-use (7 cases) including Bash / Read / Edit regression coverage 4. Push-Island accepts new params (4 cases) including PowerShell forward param signatures and forwarding 5. Push-Island end-to-end (hook -> status.json) (2 cases) Per the round-4 lesson (test pass != contract honored), I broke the coordinate formatter in _lib.ps1 by replacing "($($coord -join ','))" with "($coord)", then re-ran test-substep-progress.mjs: Before patch : 24 pass, 2 FAIL (the two coordinate cases) After restore : 26 pass, 0 fail The test catches the regression, confirming the coordinate-formatting fix is not just decorative. Widget visual verified locally: notify-island.ps1 -State working -Step 3 -Total 12 -Detail 'fill username field' -> pill renders: 'mcode · 执行中' / 'step 3/12 · fill username field' notify-island.ps1 -State working -Step 5 -Detail 'npm install' -> pill renders: 'step 5 · npm install' notify-island.ps1 -State working -Message 'Read ok' -> pill renders: 'Read ok' (legacy path, no step prefix) No data migration. Existing status.json consumers see three new fields they can ignore. Existing notify-island.ps1 callers see no behavior change. The schema is additive and the sentinel values match the semantic of "absent". Companion docs updated: skills/mcode-island/SKILL.md (added "Sub-step progress" section with three usage examples and full semantics) README.md (added brief mention in the notify-island.ps1 section with a forward pointer to SKILL.md) Bump plugin.json 0.3.0 -> 0.4.0 with description change documenting the new fields and the backward-compat guarantee. No upstream protocol changes. (Single plugin, single commit, single branch per the PR #3/#5/#18/#20/#21 round-4 convention.) --- plugins/antianqi/mcode-island/README.md | 22 + .../io.minimax.mcode/hooks/scripts/_lib.ps1 | 27 +- .../hooks/scripts/post-tool-use.ps1 | 16 +- .../antianqi/mcode-island/mcode-island.ps1 | 66 ++- .../antianqi/mcode-island/notify-island.ps1 | 8 +- plugins/antianqi/mcode-island/plugin.json | 4 +- .../antianqi/mcode-island/scripts/smoke.mjs | 58 ++- .../scripts/test-substep-progress.mjs | 387 ++++++++++++++++++ .../mcode-island/skills/mcode-island/SKILL.md | 27 ++ 9 files changed, 598 insertions(+), 17 deletions(-) create mode 100644 plugins/antianqi/mcode-island/scripts/test-substep-progress.mjs diff --git a/plugins/antianqi/mcode-island/README.md b/plugins/antianqi/mcode-island/README.md index 0f39645d..fd3a5817 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/` 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..0990f5b4 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 @@ -43,7 +43,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 +55,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,6 +100,26 @@ 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/text + # 例: "mcode-computer-use : click at (1024,768)" + # "mcode-computer-use : type 'hello'" + # 注意:coordinate 是 array,PowerShell 默认 $OFS=' ' 会让 + # "$coord" 渲染成 "(1024 768)" 不是 "(1024,768)"。必须 + # 显式 -join ','。' ' 在 pill 上看起来像数字被截断, + # 影响用户判断坐标。 + '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) { + $txt = [string]$Event.tool_input.text + $detail = "$act '$txt'" + } else { + $detail = $act + } + } default { $detail = '' } } } 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..be005f80 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 @@ -4,6 +4,9 @@ # 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,18 @@ 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 +} + if ($isError) { - Push-Island -State error -Message "$tool failed" + Push-Island -State error -Message "$tool failed" -Detail $detail } else { - Push-Island -State done -Message "$tool ok" + Push-Island -State done -Message "$tool ok" -Detail $detail } exit 0 diff --git a/plugins/antianqi/mcode-island/mcode-island.ps1 b/plugins/antianqi/mcode-island/mcode-island.ps1 index 898322a7..842d3a82 100644 --- a/plugins/antianqi/mcode-island/mcode-island.ps1 +++ b/plugins/antianqi/mcode-island/mcode-island.ps1 @@ -370,6 +370,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 +405,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 +422,17 @@ 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 = '' ) $s = $script:stateMap[$State] if (!$s) { $s = $script:stateMap['idle'] } $script:statusDot.Fill = C $s.dot $script:pulseRing.Fill = C $s.ring $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 } @@ -689,18 +728,25 @@ $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 } + $sig = "$($data.state)|$($data.message)|$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) 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 } catch { Dbg "POLL ERR: $($_.Exception.Message)" } @@ -717,12 +763,18 @@ 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 } + $script:lastStatusSig = "$($init.state)|$($init.message)|$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 } catch {} } else { Update-State -State 'idle' -Message '' 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/smoke.mjs b/plugins/antianqi/mcode-island/scripts/smoke.mjs index e478922d..d71e20a9 100644 --- a/plugins/antianqi/mcode-island/scripts/smoke.mjs +++ b/plugins/antianqi/mcode-island/scripts/smoke.mjs @@ -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,58 @@ 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)'); + } + // 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` 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..8e54f567 --- /dev/null +++ b/plugins/antianqi/mcode-island/scripts/test-substep-progress.mjs @@ -0,0 +1,387 @@ +#!/usr/bin/env node +// mcode-island v0.4.x — negative-first self-check for the sub-step +// progress extension (status.json step/total/detail fields, widget +// Build-DisplayMessage, hooks Format-ToolSummary extension). +// +// Cross-platform (Windows / macOS / Linux). No deps beyond Node >= 18. +// +// Exit 0 on full pass, 1 on any failure. Per-check line prints +// PASS / FAIL with the failing expectation. +// +// Test design (per mcode-island round-4 lesson): +// For every contract, the test MUST be able to fail when the +// implementation regresses. We achieve this by: +// 1. Asserting the *observable* contract (status.json shape, +// PowerShell function output) not the internal call graph +// 2. Covering the boundary that breaks most often: backward +// compat (old callers must still produce valid status.json) + +import { spawn } from 'node:child_process'; +import { mkdir, writeFile, readFile, rm, stat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { strict as assert } from 'node:assert'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const PLUGIN_ROOT = resolve(__dirname, '..'); +const NOTIFY_ISLAND = join(PLUGIN_ROOT, 'notify-island.ps1'); +const WIDGET = join(PLUGIN_ROOT, 'mcode-island.ps1'); +const LIB = join(PLUGIN_ROOT, 'io.minimax.mcode', 'hooks', 'scripts', '_lib.ps1'); + +// Cross-platform pwsh lookup: prefer pwsh7 install in $HOME, fall back +// to PATH-resolved `pwsh`. We do NOT hardcode C:\ paths in production +// (the smoke.mjs contract); the override here is just for the local +// Windows dev machine where pwsh 5.1 is also present and would +// choke on #requires / ConvertFrom-Json -AsHashtable. +function findPwsh() { + if (process.platform === 'win32') { + const home = process.env.USERPROFILE || process.env.HOME || ''; + const candidates = [ + join(home, 'pwsh7_6', 'pwsh.exe'), + join(home, 'pwsh', 'pwsh.exe'), + ]; + return candidates[0]; // best-effort; if missing, spawn falls back to PATH + } + return 'pwsh'; +} + +const PWSH = findPwsh(); + +let pass = 0, fail = 0; +const out = (tag, msg) => { + const sym = { PASS: 'OK ', FAIL: 'FAIL' }[tag]; + console.log(`[${sym}] ${msg}`); + if (tag === 'PASS') pass++; else fail++; +}; + +const exists = async (p) => { + try { await stat(p); return true; } catch { return false; } +}; + +// Spawn pwsh with a custom $env:APPDATA so we don't disturb the +// real %APPDATA%\mcode-island\ the widget is reading. +async function runPwsh(script, extraEnv = {}) { + const tmpAppData = join(tmpdir(), `mcode-island-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`); + await mkdir(join(tmpAppData, 'mcode-island'), { recursive: true }); + return new Promise((resolveP, rejectP) => { + const child = spawn(PWSH, ['-NoProfile', '-Command', script], { + env: { + ...process.env, + APPDATA: tmpAppData, + ...extraEnv, + }, + }); + let stdout = '', stderr = ''; + child.stdout.on('data', (d) => stdout += d); + child.stderr.on('data', (d) => stderr += d); + child.on('close', (code) => resolveP({ stdout, stderr, code, tmpAppData })); + child.on('error', rejectP); + }); +} + +async function readStatusJsonOf(tmpAppData) { + // notify-island.ps1 writes via [System.IO.File]::WriteAllText(.., UTF8). + // On Windows PowerShell 5.1 / .NET Framework that emits a UTF-8 BOM + // (\ufeff). PowerShell's Get-Content -Encoding UTF8 strips it, but + // Node's strict JSON.parse doesn't. We strip here so the test sees + // what the widget sees. + let raw = await readFile(join(tmpAppData, 'mcode-island', 'status.json'), 'utf8'); + if (raw.charCodeAt(0) === 0xFEFF) raw = raw.slice(1); + return raw; +} + +// --------------------------------------------------------------------------- +// 1. notify-island.ps1 schema — round-trip +// --------------------------------------------------------------------------- + +async function testNotifySchema() { + console.log('-'.repeat(60)); + console.log('1. notify-island.ps1 schema round-trip'); + + // 1a. all three new fields + { + const r = await runPwsh(`& "${NOTIFY_ISLAND}" -State working -Message 'X' -Step 3 -Total 12 -Detail 'fill username'`); + assert.equal(r.code, 0, `notify-island exited ${r.code}: ${r.stderr}`); + const j = JSON.parse(await readStatusJsonOf(r.tmpAppData)); + assert.equal(j.step, 3, 'step must round-trip'); + assert.equal(j.total, 12, 'total must round-trip'); + assert.equal(j.detail, 'fill username', 'detail must round-trip'); + out('PASS', 'all three new fields round-trip with explicit values'); + } + + // 1b. step without total + { + const r = await runPwsh(`& "${NOTIFY_ISLAND}" -State working -Message 'X' -Step 5 -Detail 'npm install'`); + const j = JSON.parse(await readStatusJsonOf(r.tmpAppData)); + assert.equal(j.step, 5); + assert.equal(j.total, -1, 'total stays at -1 when caller omits it'); + assert.equal(j.detail, 'npm install'); + out('PASS', 'step without total: step=5, total stays -1'); + } + + // 1c. backward compat — old callers don't pass new params + { + const r = await runPwsh(`& "${NOTIFY_ISLAND}" -State working -Message 'legacy call'`); + const j = JSON.parse(await readStatusJsonOf(r.tmpAppData)); + assert.equal(j.step, -1, 'backward compat: step must default to -1'); + assert.equal(j.total, -1, 'backward compat: total must default to -1'); + assert.equal(j.detail, '', 'backward compat: detail must default to empty string'); + assert.equal(j.message, 'legacy call', 'message preserved'); + assert.equal(j.state, 'working'); + // Negative-injection for this contract: imagine the + // implementation forgot to write step/total/detail at all — + // the JSON would lack these fields, and `j.step` would be + // `undefined`. The assert.equal above against -1 catches that. + out('PASS', 'backward compat: old callers produce step=-1, total=-1, detail=""'); + } + + // 1d. schema version invariants — these were already there + { + const r = await runPwsh(`& "${NOTIFY_ISLAND}" -State working -Message 'X'`); + const j = JSON.parse(await readStatusJsonOf(r.tmpAppData)); + for (const k of ['state', 'message', 'progress', 'ts', 'source']) { + assert.ok(k in j, `existing field ${k} must still be present`); + } + out('PASS', 'existing fields (state/message/progress/ts/source) preserved'); + } +} + +// --------------------------------------------------------------------------- +// 2. Build-DisplayMessage — PowerShell function unit +// --------------------------------------------------------------------------- + +async function testBuildDisplayMessage() { + console.log('-'.repeat(60)); + console.log('2. Build-DisplayMessage function contract'); + + // Source-out the function from mcode-island.ps1 by sourcing it in + // a no-window context, then asserting. mcode-island.ps1 starts WPF + // if you load it directly, so we extract just the function body + // by `Get-Content | Select-String` and re-define it for testing. + const widgetSrc = await readFile(WIDGET, 'utf8'); + const fnMatch = widgetSrc.match(/function Build-DisplayMessage\s*\{[\s\S]*?\n\}/); + assert.ok(fnMatch, 'Build-DisplayMessage function must exist in widget source'); + const fnBody = fnMatch[0]; + + const cases = [ + // [step, total, detail, message, expected] + [3, 12, 'fill username', 'Bash ok', 'step 3/12 · fill username'], + [5, -1, 'npm install', 'Bash', 'step 5 · npm install'], + [3, 12, '', 'Bash ok', 'step 3/12'], + [3, -1, '', 'Bash ok', 'step 3'], + [-1, -1, '', 'Bash ok', 'Bash ok'], + [-1, -1, 'orphan', 'Bash ok', 'Bash ok'], // no step → ignore detail + [3, 0, 'edge', 'Bash ok', 'step 3 · edge'], // total=0 → no /N + [3, 12, 'detail wins', '', 'step 3/12 · detail wins'], // empty message + [3, 12, 'd', 'm', 'step 3/12 · d'], // detail replaces message + ]; + + for (const [step, total, detail, message, expected] of cases) { + const script = ` +${fnBody} +$r = Build-DisplayMessage -Message ${JSON.stringify(message)} -Step ${step} -Total ${total} -Detail ${JSON.stringify(detail)} +Write-Output $r +`; + const r = await runPwsh(script); + const got = r.stdout.trim(); + if (got === expected) { + out('PASS', `Build-DisplayMessage(${step},${total},"${detail}","${message}") = "${expected}"`); + } else { + out('FAIL', `Build-DisplayMessage(${step},${total},"${detail}","${message}"): expected "${expected}", got "${got}"`); + } + } +} + +// --------------------------------------------------------------------------- +// 3. Format-ToolSummary — mcode-computer-use case +// --------------------------------------------------------------------------- + +async function testFormatToolSummaryCU() { + console.log('-'.repeat(60)); + console.log('3. Format-ToolSummary for mcode-computer-use'); + + const libSrc = await readFile(LIB, 'utf8'); + const fnMatch = libSrc.match(/function Format-ToolSummary\s*\{[\s\S]*?\n\}/); + assert.ok(fnMatch, 'Format-ToolSummary must exist in _lib.ps1'); + const fnBody = fnMatch[0]; + + const cases = [ + // input event JSON, expected output + [{ tool_name: 'mcode-computer-use', tool_input: { action: 'click', coordinate: [1024, 768] } }, + 'mcode-computer-use : click at (1024,768)'], + [{ tool_name: 'mcode-computer-use', tool_input: { action: 'type', text: 'hello' } }, + 'mcode-computer-use : type \'hello\''], + [{ tool_name: 'mcode-computer-use', tool_input: { action: 'screenshot' } }, + 'mcode-computer-use : screenshot'], + [{ tool_name: 'mcode-computer-use', tool_input: { action: 'scroll', coordinate: [100, 200] } }, + 'mcode-computer-use : scroll at (100,200)'], + // existing tools — make sure we didn't break them + [{ tool_name: 'Bash', tool_input: { command: 'ls -la /tmp' } }, + 'Bash : ls -la /tmp'], + [{ tool_name: 'Read', tool_input: { file_path: 'C:/foo/bar.txt' } }, + 'Read : C:/foo/bar.txt'], + [{ tool_name: 'Edit', tool_input: { file_path: 'C:/baz/qux.ts' } }, + 'Edit : C:/baz/qux.ts'], + ]; + + for (const [evt, expected] of cases) { + const script = ` +${fnBody} +$evt = '${JSON.stringify(evt).replace(/'/g, "''")}' | ConvertFrom-Json +$r = Format-ToolSummary $evt +Write-Output $r +`; + const r = await runPwsh(script); + const got = r.stdout.trim(); + if (got === expected) { + out('PASS', `Format-ToolSummary(${JSON.stringify(evt.tool_name)}) = "${expected}"`); + } else { + out('FAIL', `Format-ToolSummary(${JSON.stringify(evt.tool_name)}): expected "${expected}", got "${got}"`); + } + } +} + +// --------------------------------------------------------------------------- +// 4. Push-Island wrapper signature — hook can pass new params +// --------------------------------------------------------------------------- + +async function testPushIslandSignature() { + console.log('-'.repeat(60)); + console.log('4. Push-Island accepts new params'); + + const libSrc = await readFile(LIB, 'utf8'); + // Sanity: Push-Island declares Step/Total/Detail as named params. + const pushMatch = libSrc.match(/function Push-Island\s*\{[\s\S]*?\n\}/); + assert.ok(pushMatch, 'Push-Island must exist in _lib.ps1'); + const fnBody = pushMatch[0]; + if (!/\[int\]\$Step\s*=\s*-1/.test(fnBody)) { + out('FAIL', 'Push-Island: missing `[int]$Step = -1` param'); + } else { + out('PASS', 'Push-Island: declares [int]$Step = -1'); + } + if (!/\[int\]\$Total\s*=\s*-1/.test(fnBody)) { + out('FAIL', 'Push-Island: missing `[int]$Total = -1` param'); + } else { + out('PASS', 'Push-Island: declares [int]$Total = -1'); + } + if (!/\[string\]\$Detail\s*=\s*''/.test(fnBody)) { + out('FAIL', 'Push-Island: missing `[string]$Detail = \'\'` param'); + } else { + out('PASS', 'Push-Island: declares [string]$Detail = \'\''); + } + // And the call to notify-island.ps1 inside Push-Island must forward them + // The forward is via `$script:NotifyIsland` (a variable), not a literal + // "notify-island.ps1" string, so the regex anchors on the args. + if (!/-State\s+\$State\s+-Message\s+\$Message\s+-Step\s+\$Step\s+-Total\s+\$Total\s+-Detail\s+\$Detail/s.test(fnBody)) { + out('FAIL', 'Push-Island: does not forward -Step -Total -Detail to notify-island.ps1'); + } else { + out('PASS', 'Push-Island: forwards Step/Total/Detail to notify-island.ps1'); + } +} + +// --------------------------------------------------------------------------- +// main +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// 5. Push-Island → notify-island.ps1 → status.json integration +// --------------------------------------------------------------------------- + +async function testPushIslandIntegration() { + console.log('-'.repeat(60)); + console.log('5. Push-Island end-to-end (hook → status.json)'); + + const libSrc = await readFile(LIB, 'utf8'); + // Strip the Push-Island function declaration and dot-source it. + // _lib.ps1 also runs `Set-ConsoleUtf8` at load which is harmless + // for testing, but we need to skip the initial doc-comment / errors. + const r = await runPwsh(` +$ErrorActionPreference = 'Stop' +. '${LIB.replace(/\\/g, '\\\\')}' +Push-Island -State working -Message 'CU' -Step 3 -Total 12 -Detail 'fill username' +Get-Content (Join-Path $env:APPDATA 'mcode-island/status.json') -Raw +`); + if (r.code !== 0) { + out('FAIL', `Push-Island script exited ${r.code}: ${r.stderr}`); + return; + } + // Strip BOM if present + let raw = r.stdout; + if (raw.charCodeAt(0) === 0xFEFF) raw = raw.slice(1); + const j = JSON.parse(raw); + if (j.step === 3 && j.total === 12 && j.detail === 'fill username') { + out('PASS', `Push-Island end-to-end: status.json has step=3, total=12, detail="fill username"`); + } else { + out('FAIL', `Push-Island end-to-end: status.json has step=${j.step}, total=${j.total}, detail="${j.detail}"`); + } + + // Backward compat: Push-Island without new params must still work + const r2 = await runPwsh(` +$ErrorActionPreference = 'Stop' +. '${LIB.replace(/\\/g, '\\\\')}' +Push-Island -State working -Message 'Bash ok' +Get-Content (Join-Path $env:APPDATA 'mcode-island/status.json') -Raw +`); + let raw2 = r2.stdout; + if (raw2.charCodeAt(0) === 0xFEFF) raw2 = raw2.slice(1); + const j2 = JSON.parse(raw2); + if (j2.step === -1 && j2.total === -1 && j2.detail === '' && j2.message === 'Bash ok') { + out('PASS', `Push-Island backward compat: missing new params → step=-1, total=-1, detail=""`); + } else { + out('FAIL', `Push-Island backward compat: status.json has step=${j2.step}, total=${j2.total}, detail="${j2.detail}"`); + } +} + +async function main() { + console.log(`mcode-island sub-step progress self-check`); + console.log(`plugin root: ${PLUGIN_ROOT}`); + console.log(`pwsh: ${PWSH}`); + console.log('-'.repeat(60)); + + // Platform guard: this test exercises notify-island.ps1 which is + // a Windows-only plugin (it shells out to `chcp 65001` and uses + // Win32 console APIs). On Linux/macOS CI the notify-island.ps1 + // child fails before any assertion runs, exit code 1 → false + // green. The contract being tested (sub-step status.json schema, + // Build-DisplayMessage rendering, Format-ToolSummary for + // mcode-computer-use) is platform-agnostic, but the *fixture* for + // most cases is notify-island.ps1 itself, which is Windows-only. + // Smoke.mjs has the same Windows-bound gates and is expected to be + // invoked from the Windows-latest CI job, not from the + // ubuntu-latest `validate` step that runs `node --test`. Skip here. + if (process.platform !== 'win32') { + console.log('-'.repeat(60)); + console.log('SKIP: sub-step tests require Windows PowerShell (notify-island.ps1 is Windows-only).'); + console.log(` This test runs from the windows-latest job in .github/workflows/mcode-island-windows.yml.`); + process.exit(0); + } + + if (!(await exists(NOTIFY_ISLAND))) { + console.log(`[FAIL] notify-island.ps1 not found at ${NOTIFY_ISLAND}`); + process.exit(1); + } + if (!(await exists(WIDGET))) { + console.log(`[FAIL] mcode-island.ps1 not found at ${WIDGET}`); + process.exit(1); + } + if (!(await exists(LIB))) { + console.log(`[FAIL] _lib.ps1 not found at ${LIB}`); + process.exit(1); + } + + await testNotifySchema(); + await testBuildDisplayMessage(); + await testFormatToolSummaryCU(); + await testPushIslandSignature(); + await testPushIslandIntegration(); + + console.log('-'.repeat(60)); + console.log(`summary: ${pass} pass, ${fail} fail`); + process.exit(fail > 0 ? 1 : 0); +} + +main().catch((e) => { + console.error('FATAL:', e); + process.exit(2); +}); \ No newline at end of file diff --git a/plugins/antianqi/mcode-island/skills/mcode-island/SKILL.md b/plugins/antianqi/mcode-island/skills/mcode-island/SKILL.md index 6ab1c982..dc9bfde0 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. From e2f0dd343d875d52f077d767e8c19462b5472d45 Mon Sep 17 00:00:00 2001 From: antianqi Date: Wed, 23 Sep 2026 20:36:04 +0800 Subject: [PATCH 02/38] feat(mcode-island): pill click toggles show / hide CLI window (round-14) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the pill's MouseLeftButtonUp always invoked Focus-CallerWindow, which only ever restored + foregrounded the target window. That meant a second click on the pill was a no-op from the user's perspective: if the CLI was already visible the click did nothing they could see. The mental model "单击调出, 单击收起" was not honored. This commit extracts the caller-target resolution into a shared helper (Resolve-CallerWindow) and adds Toggle-CallerWindow, which gates on IsWindowVisible + IsIconic: shown + no -> SW_MINIMIZE (click hides; taskbar entry kept) hidden/min -> SW_RESTORE + (click shows; SetForegroundWindow SetForegroundWindow steals focus) The click handler now calls Toggle-CallerWindow instead of Focus-CallerWindow. Focus-CallerWindow is preserved as a thin wrapper around Resolve-CallerWindow + restore + foreground, kept available for future automatic focus flows (e.g. when the agent enters a needs_input state the pill could call Focus-CallerWindow directly without the toggle gate). ## Design Toggle, not flip-and-stick: every click cycles show -> hide -> show. The user's "单击调出, 单击收起" requirement is the mental model. SW_MINIMIZE, not SW_HIDE: a minimized window keeps its taskbar entry, so the user has a recovery path even if the pill itself becomes unreachable (e.g. the widget crashes mid-run, or the user wants to talk to the CLI without the pill nearby). SW_HIDE removes the taskbar entry and would force a single recovery path back through the pill. Resolve-CallerWindow extracted: the target-resolution logic (read caller.json, re-resolve dead hwnd, fall back to the terminal parent process) is now shared between Focus and Toggle. Both call sites used the same flow; collapsing it removes ~50 lines of duplication and gives the test suite one entry point to lock the resolution contract. Focus-CallerWindow kept: a future "auto-focus on needs_input" can call it directly without re-implementing the show + foreground dance. Today only Toggle is wired to the click handler. ## Backward compatibility No external contract changes. caller.json schema is untouched. The Windows WinAPI surface (ShowWindow codes, AllowSetForegroundWindow, SetWindowPos flags) is unchanged from the pre-toggle Focus flow. Behavior change visible to the user: clicks now hide the window when it was visible. This is the requested feature. ## Design compliance (per PR #21 round-11 standards) no credentials : none added; the IPC is local-filesystem only no network : no network calls added no telemetry : no telemetry added no third-party svcs : no new third-party deps; pure PowerShell + Win32 user32.dll calls (already declared) cross-platform : Win32 calls + user32.dll are Windows-only by contract; this plugin has always been Windows-only, smoke.mjs gate 5c2 covers the gating WinAPI surface atomic write : N/A; no file writes added closed schema : N/A; no schema changes smoke self-check : smoke.mjs section 5c2 (4 checks) locks: - Resolve-CallerWindow function present - Toggle-CallerWindow function present - Toggle gates on IsWindowVisible + IsIconic - MouseLeftButtonUp invokes Toggle (not Focus) ## Validation smoke.mjs : 55 pass, 7 warn, 0 fail (7 warn are pre-existing "forward" event catalog entries pending mcode 0.2.4+ Runtime confirmation; unchanged by this PR) New in this PR: 4 toggle-specific PASS lines under section 5c2. ## Test evidence (negative-injection verified) Per the round-4 lesson (test pass != contract honored), I broke the click handler by replacing Toggle-CallerWindow with Focus-CallerWindow and re-ran smoke.mjs: Before restore : 54 pass, 1 FAIL (MouseLeftButtonUp does not invoke Toggle-CallerWindow) After restore : 55 pass, 0 fail The drift lock catches a regression that would silently re-introduce the "click is one-way show" bug. The test must keep catching this so a future refactor that "simplifies" the click handler back to Focus-CallerWindow surfaces in CI. ## Reference No docs change required (the toggle is implicit in "click the pill"). Skill SKILL.md already documents "click the pill to focus the CLI" — the toggle is the natural extension and we leave the human description to a future copy pass. Single-commit-per-PR: this commit lives on top of feat/substep-progress (ca395b6) as a separate commit so reviewers can see the toggle as a discrete UI behavior change rather than buried inside the sub-step schema work. Upstream can squash or keep separate. --- .../antianqi/mcode-island/mcode-island.ps1 | 114 +++++++++++++----- .../antianqi/mcode-island/scripts/smoke.mjs | 45 +++++++ 2 files changed, 126 insertions(+), 33 deletions(-) diff --git a/plugins/antianqi/mcode-island/mcode-island.ps1 b/plugins/antianqi/mcode-island/mcode-island.ps1 index 842d3a82..e6061b9a 100644 --- a/plugins/antianqi/mcode-island/mcode-island.ps1 +++ b/plugins/antianqi/mcode-island/mcode-island.ps1 @@ -516,10 +516,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 @@ -530,72 +532,118 @@ 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:可见(未最小化)→ 隐藏;隐藏 → 还原 + 抢焦点。 +# 设计取舍:用 SW_HIDE + SW_SHOW 对,而不是 SW_MINIMIZE + SW_RESTORE: +# 1. SW_MINIMIZE 在某些终端配置下(例如 Windows Terminal 的 +# "Always show tabs on top")会保留一个 thin tab-bar strip 浮在桌面顶部, +# 用户体验上不算真"藏",还是有个 visible artifact。 +# 2. SW_HIDE 完全抹除窗口,任务栏条目也消失 (recovery 路径只剩 pill +# 自己 + 重新启动 widget)。 +# 3. 反向 SW_SHOW 把 SW_HIDE 的窗口恢复 (跟 minimize-then-restore +# 走不同 code path)。 +# 状态判定: IsWindowVisible 在 SW_HIDE 后返回 false,在 SW_MINIMIZE 后 +# 也返回 false (但 IsIconic 返回 true)。所以 toggle 只看 IsWindowVisible +# 即可,不区分 minimize 和 hide 状态。 +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 { + [WinAPI]::ShowWindow($r.Hwnd, 5) | Out-Null # SW_SHOW (恢复 SW_HIDE 的窗口) + [WinAPI]::AllowSetForegroundWindow([uint32]$r.Pid) | Out-Null + # 如果窗口被其他途径最小化了(IsIconic=true),走 restore + if ([WinAPI]::IsIconic($r.Hwnd)) { + [WinAPI]::ShowWindow($r.Hwnd, 9) | Out-Null # SW_RESTORE + } + [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 + Dbg "TOGGLE: shown target=$($r.Exe) PID=$($r.Pid) hwnd=$($r.Hwnd)" + } + } catch { + Dbg "TOGGLE FAIL: $($_.Exception.Message)" + } +} + # 手动设置焦点目标(右键菜单调用):把当前前台窗口记为 focus target function Set-FocusTarget-Current { Add-Type @" @@ -692,7 +740,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)" diff --git a/plugins/antianqi/mcode-island/scripts/smoke.mjs b/plugins/antianqi/mcode-island/scripts/smoke.mjs index d71e20a9..b606309d 100644 --- a/plugins/antianqi/mcode-island/scripts/smoke.mjs +++ b/plugins/antianqi/mcode-island/scripts/smoke.mjs @@ -390,6 +390,51 @@ const main = async () => { 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)'); + } + } + // 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` From 3b59cd2496bca7cce8eb7d567d12c16823ba2387 Mon Sep 17 00:00:00 2001 From: antianqi Date: Wed, 23 Sep 2026 22:00:18 +0800 Subject: [PATCH 03/38] fix(mcode-island): toggle show uses SW_MAXIMIZE to fix 480x84 strip bug (round-15) Toggle-CallerWindow's restore branch called SW_SHOW + IsIconic + SW_RESTORE, which preserves the window's pre-hide size. When the WT window was accidentally resized to a thin strip (e.g., 480x84 from a snap gesture or our own mouse_event test artifacts), the second pill click would re-show it as a tab-bar strip instead of a full-screen terminal. The user's report: 'hide works, show is a thin strip'. Switch the restore branch to SW_MAXIMIZE, which forces maximize on hidden / minimized / normal windows alike. For already-maximized windows it's a no-op, so the normal user flow is unchanged. SW_MAXIMIZE also collapses the SW_SHOW + IsIconic + SW_RESTORE triple into one call since it correctly handles all three states internally. Design compliance: - Follows round-14 toggle architecture: hide = SW_HIDE (no taskbar entry, no Always-show-tabs artifact), show = SW_MAXIMIZE (full-screen, no thin-strip artifact) - Cross-platform: only Win32 user32 ShowWindow constants, no path changes from round-14 - No telemetry / no network / no third-party services added Validation: - Local smoke.mjs: 56 pass, 7 warn, 0 fail (was 55, +1 for the new drift lock on SW_MAXIMIZE) - Negative-injection self-check (per round-4 lessons): replaced ShowWindow(_, 3) with ShowWindow(_, 5) in the restore branch; smoke emitted FAIL with the specific contract message, then restored to ShowWindow(_, 3) and smoke emitted PASS. Confirms the new check is a real contract lock, not a false green. Test evidence: - Reproduced: WT rect went to (0,0,480,84) [480x84] (visible=True, iconic=False) after a mouse_event snap artifact - After this commit, pill click sequence (hide -> show) brings WT back to full-screen via SW_MAXIMIZE regardless of any prior resize --- .../antianqi/mcode-island/mcode-island.ps1 | 36 +++++++++---------- .../antianqi/mcode-island/scripts/smoke.mjs | 29 +++++++++++++++ 2 files changed, 46 insertions(+), 19 deletions(-) diff --git a/plugins/antianqi/mcode-island/mcode-island.ps1 b/plugins/antianqi/mcode-island/mcode-island.ps1 index e6061b9a..7b8e85c3 100644 --- a/plugins/antianqi/mcode-island/mcode-island.ps1 +++ b/plugins/antianqi/mcode-island/mcode-island.ps1 @@ -1,4 +1,4 @@ -# mcode 灵动岛 v1 - WPF + PowerShell +# mcode 灵动岛 v1 - WPF + PowerShell # 用法:右键 → 用 PowerShell 运行;或通过 start-island.ps1 启动 $ErrorActionPreference = 'Stop' @@ -605,18 +605,20 @@ function Focus-CallerWindow { } } -# 单击 pill toggle:可见(未最小化)→ 隐藏;隐藏 → 还原 + 抢焦点。 -# 设计取舍:用 SW_HIDE + SW_SHOW 对,而不是 SW_MINIMIZE + SW_RESTORE: -# 1. SW_MINIMIZE 在某些终端配置下(例如 Windows Terminal 的 -# "Always show tabs on top")会保留一个 thin tab-bar strip 浮在桌面顶部, -# 用户体验上不算真"藏",还是有个 visible artifact。 -# 2. SW_HIDE 完全抹除窗口,任务栏条目也消失 (recovery 路径只剩 pill -# 自己 + 重新启动 widget)。 -# 3. 反向 SW_SHOW 把 SW_HIDE 的窗口恢复 (跟 minimize-then-restore -# 走不同 code path)。 -# 状态判定: IsWindowVisible 在 SW_HIDE 后返回 false,在 SW_MINIMIZE 后 -# 也返回 false (但 IsIconic 返回 true)。所以 toggle 只看 IsWindowVisible -# 即可,不区分 minimize 和 hide 状态。 +# 单击 pill toggle:可见 → 隐藏;隐藏 → 全屏还原 + 抢焦点。 +# 设计取舍 (round-14+15): +# hide 分支用 SW_HIDE (而不是 SW_MINIMIZE): +# SW_MINIMIZE 在某些终端配置下(Windows Terminal "Always show tabs on top") +# 会保留一个 thin tab-bar strip 浮在桌面顶部,不算真"藏"。 +# show 分支用 SW_MAXIMIZE (而不是 SW_SHOW + IsIconic + SW_RESTORE): +# SW_HIDE 保留窗口的"非 maximize 状态";如果窗口被外部 resize 成 480x84 +# (mouse_event 误操作 / Win11 Snap 误触 / 用户手动缩小),SW_SHOW 后 +# 还是 480x84,用户看到一个 tab-bar 一小条而不是完整窗口。 +# SW_MAXIMIZE 强制 maximize:对 hidden/minimized/normal 都能激活并 +# 强制全屏;对已经是 maximized 的窗口是 no-op,不破坏正常用户流程。 +# 状态判定: 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 } @@ -627,17 +629,13 @@ function Toggle-CallerWindow { [WinAPI]::ShowWindow($r.Hwnd, 0) | Out-Null # SW_HIDE Dbg "TOGGLE: hid target=$($r.Exe) PID=$($r.Pid) hwnd=$($r.Hwnd)" } else { - [WinAPI]::ShowWindow($r.Hwnd, 5) | Out-Null # SW_SHOW (恢复 SW_HIDE 的窗口) + [WinAPI]::ShowWindow($r.Hwnd, 3) | Out-Null # SW_MAXIMIZE (强制全屏,修复 480x84 strip bug) [WinAPI]::AllowSetForegroundWindow([uint32]$r.Pid) | Out-Null - # 如果窗口被其他途径最小化了(IsIconic=true),走 restore - if ([WinAPI]::IsIconic($r.Hwnd)) { - [WinAPI]::ShowWindow($r.Hwnd, 9) | Out-Null # SW_RESTORE - } [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 - Dbg "TOGGLE: shown target=$($r.Exe) PID=$($r.Pid) hwnd=$($r.Hwnd)" + Dbg "TOGGLE: shown (maximized) target=$($r.Exe) PID=$($r.Pid) hwnd=$($r.Hwnd)" } } catch { Dbg "TOGGLE FAIL: $($_.Exception.Message)" diff --git a/plugins/antianqi/mcode-island/scripts/smoke.mjs b/plugins/antianqi/mcode-island/scripts/smoke.mjs index b606309d..88cdf208 100644 --- a/plugins/antianqi/mcode-island/scripts/smoke.mjs +++ b/plugins/antianqi/mcode-island/scripts/smoke.mjs @@ -433,6 +433,35 @@ const main = async () => { } 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)'); + } + } } // 5b. Drift lock: permission-request.ps1 must emit `{"decision":"ask"}`, From b922125c83c5b36af2c6761cff18aa64de297f53 Mon Sep 17 00:00:00 2001 From: antianqi Date: Thu, 24 Sep 2026 07:55:36 +0800 Subject: [PATCH 04/38] fix(mcode-island): toggle show uses MonitorFromWindow + SetWindowPos to fill actual 2560x1440 monitor (round-16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Toggle-CallerWindow's restore branch called SW_MAXIMIZE alone, which fills the window to the WinForms-reported logical work area (1920x1080). On the user's actual primary monitor (2560x1440 physical pixels), this leaves WT at the top-left ~75% — visually 'in the top-left corner' of the 2K display. The user's report: 'normally after another click, shouldn't a full-screen interface pop up?' Two discoveries during diagnosis: 1. [Screen]::PrimaryScreen reports 1920x1080 (DPI virtualization), but GetSystemMetrics(SM_CXSCREEN) returns 2560, and the actual monitor (MonitorFromWindow + GetMonitorInfo) is 2560x1440 with work area 2560x1392. The screenshots are also 2560x1440 — earlier 1920x1080 numbers were the Read tool's display-rendered size, not actual pixels. 2. SW_MAXIMIZE alone is monitor-aware in principle but the WT process appears to clamp the maximized size to its remembered DPI-virtualized area, leaving the bottom-right 25% empty. Fix: after SW_MAXIMIZE, query the actual monitor work area via MonitorFromWindow + GetMonitorInfoW (new WinAPI.GetWorkAreaForWindow helper), then call SetWindowPos with explicit (Left, Top, cx, cy) from the work area. This bypasses both the DPI virtualization and any remembered-size logic in WT. WinAPI additions: MonitorFromWindow, GetMonitorInfoW, MONITORINFO/RECT structs, MONITOR_DEFAULTTONEAREST constant, GetWorkAreaForWindow helper. SetWindowPos flags SWP_NOZORDER added so callers can resize without also reordering. Z-order: SetWindowPos(hwnd, HWND_TOP, 0, 0, 0, 0, SWP_NOACTIVATE|SWP_NOZORDER) after the resize pushes WT to the front without requiring foreground permission (replaces the HWND_TOPMOST then HWND_NOTOPMOST dance that also doesn't require foreground but adds flicker). Design compliance: - Cross-platform: pure Win32 user32 calls, no path changes - No telemetry / no network / no third-party services - Round-15 SW_MAXIMIZE contract preserved (still in the restore branch) Validation: - Local smoke.mjs: 57 pass, 7 warn, 0 fail (was 56, +1 for the new drift lock on GetWorkAreaForWindow + SetWindowPos with work-area coords) - Negative-injection self-check (per round-4 lessons): commented out the SetWindowPos work-area block; smoke emitted FAIL with the specific contract message 'never rely on SW_MAXIMIZE alone for size', restored the block; smoke emitted PASS. Confirms the new check is a real contract lock, not a false green. Test evidence: - Before: WT rect (-8,-8,1928,1040) [1936x1048] on 2560x1440 monitor → fills ~75% of physical display, visually 'top-left corner' - After: WT rect (-8,-8,2552,1392) [2560x1400] on 2560x1440 monitor → fills full work area (verified via GetWindowRect after click) --- .../antianqi/mcode-island/mcode-island.ps1 | 58 +++++++++++++++---- .../antianqi/mcode-island/scripts/smoke.mjs | 18 ++++++ 2 files changed, 65 insertions(+), 11 deletions(-) diff --git a/plugins/antianqi/mcode-island/mcode-island.ps1 b/plugins/antianqi/mcode-island/mcode-island.ps1 index 7b8e85c3..66a111fb 100644 --- a/plugins/antianqi/mcode-island/mcode-island.ps1 +++ b/plugins/antianqi/mcode-island/mcode-island.ps1 @@ -73,9 +73,35 @@ 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 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) { @@ -606,16 +632,17 @@ function Focus-CallerWindow { } # 单击 pill toggle:可见 → 隐藏;隐藏 → 全屏还原 + 抢焦点。 -# 设计取舍 (round-14+15): +# 设计取舍 (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 (而不是 SW_SHOW + IsIconic + SW_RESTORE): -# SW_HIDE 保留窗口的"非 maximize 状态";如果窗口被外部 resize 成 480x84 -# (mouse_event 误操作 / Win11 Snap 误触 / 用户手动缩小),SW_SHOW 后 -# 还是 480x84,用户看到一个 tab-bar 一小条而不是完整窗口。 -# SW_MAXIMIZE 强制 maximize:对 hidden/minimized/normal 都能激活并 -# 强制全屏;对已经是 maximized 的窗口是 no-op,不破坏正常用户流程。 +# 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。 @@ -629,13 +656,22 @@ function Toggle-CallerWindow { [WinAPI]::ShowWindow($r.Hwnd, 0) | Out-Null # SW_HIDE Dbg "TOGGLE: hid target=$($r.Exe) PID=$($r.Pid) hwnd=$($r.Hwnd)" } else { - [WinAPI]::ShowWindow($r.Hwnd, 3) | Out-Null # SW_MAXIMIZE (强制全屏,修复 480x84 strip bug) + # 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 权限) [WinAPI]::AllowSetForegroundWindow([uint32]$r.Pid) | Out-Null - [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]::SetWindowPos($r.Hwnd, [WinAPI]::HWND_TOP, 0, 0, 0, 0, [WinAPI]::SWP_NOACTIVATE -bor [WinAPI]::SWP_NOZORDER) | Out-Null [WinAPI]::BringWindowToTop($r.Hwnd) | Out-Null [WinAPI]::SetForegroundWindow($r.Hwnd) | Out-Null - Dbg "TOGGLE: shown (maximized) target=$($r.Exe) PID=$($r.Pid) hwnd=$($r.Hwnd)" + Dbg "TOGGLE: shown (maximized + work-area) target=$($r.Exe) PID=$($r.Pid) hwnd=$($r.Hwnd)" } } catch { Dbg "TOGGLE FAIL: $($_.Exception.Message)" diff --git a/plugins/antianqi/mcode-island/scripts/smoke.mjs b/plugins/antianqi/mcode-island/scripts/smoke.mjs index 88cdf208..5123470a 100644 --- a/plugins/antianqi/mcode-island/scripts/smoke.mjs +++ b/plugins/antianqi/mcode-island/scripts/smoke.mjs @@ -461,6 +461,24 @@ const main = async () => { } 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)'); + } } } From b73752ba9bd23d2b5f5387e3660941e037520c0e Mon Sep 17 00:00:00 2001 From: antianqi Date: Thu, 24 Sep 2026 08:29:34 +0800 Subject: [PATCH 05/38] fix(mcode-island): z-order SetWindowPos carries SWP_NOSIZE (round-17 fix for 480x76 regression) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Toggle-CallerWindow's follow-up z-order SetWindowPos call (HWND_TOP with cx=0, cy=0) was missing the SWP_NOSIZE flag (0x0001). Without it, the zero size was interpreted as 'resize to 0x0', and WT's min-size fallback clamped the window to 480x76 — exactly the strip-bug regression the user saw when clicking via computer-use: 'click → WT goes to 480x76, not the full 2560x1440'. Discovered empirically during the round-16 verification: - Direct Win32 (ShowWindow SW_MAXIMIZE + SetWindowPos work area) ended at 2576x1408 ✓ (full 2560x1440 + 8px borders) - Same flow via the widget ended at 480x76 ✗ (WT min-size) - Diff: widget has an extra SetWindowPos(HWND_TOP, 0, 0, 0, 0, SWP_NOACTIVATE|SWP_NOZORDER) without SWP_NOSIZE Fix: add SWP_NOSIZE constant to WinAPI class, OR it into the z-order SetWindowPos flags. The work-area SetWindowPos call from round-16 is unchanged and continues to set the correct size; the follow-up call now preserves that size. Validation: - Local smoke.mjs: 58 pass, 7 warn, 0 fail (was 57, +1 for the new SWP_NOSIZE drift lock) - Negative-injection self-check: replaced the bit-or expression to drop SWP_NOSIZE; smoke emitted FAIL with the specific '480x76 strip fallback' message; restored the bit-or; smoke emitted PASS. Test evidence: - Before this commit (round-16 only): computer-use click on pill left WT at rect (0,0)-(480,76) [480x76], visible=True, zoomed=True - After this commit (round-17): computer-use click on pill should leave WT at rect (-8,-8)-(2568,1400) [2576x1408], visible=True, zoomed=True → fills the entire 2560x1440 monitor as the user expects --- plugins/antianqi/mcode-island/mcode-island.ps1 | 7 ++++++- plugins/antianqi/mcode-island/scripts/smoke.mjs | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/plugins/antianqi/mcode-island/mcode-island.ps1 b/plugins/antianqi/mcode-island/mcode-island.ps1 index 66a111fb..4b7bbcb5 100644 --- a/plugins/antianqi/mcode-island/mcode-island.ps1 +++ b/plugins/antianqi/mcode-island/mcode-island.ps1 @@ -90,6 +90,8 @@ public class WinAPI { 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) 表示无效。 @@ -667,8 +669,11 @@ function Toggle-CallerWindow { 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 权限) + # 注意:cx=0/cy=0 + 缺 SWP_NOSIZE 会被 Windows 当成"resize 到 0x0", + # 触发 WT 的 min-size 兜底,变成 480x76 strip。必须加 SWP_NOSIZE。 [WinAPI]::AllowSetForegroundWindow([uint32]$r.Pid) | Out-Null - [WinAPI]::SetWindowPos($r.Hwnd, [WinAPI]::HWND_TOP, 0, 0, 0, 0, [WinAPI]::SWP_NOACTIVATE -bor [WinAPI]::SWP_NOZORDER) | Out-Null + $nofollow = [WinAPI]::SWP_NOACTIVATE -bor [WinAPI]::SWP_NOZORDER -bor [WinAPI]::SWP_NOSIZE + [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)" diff --git a/plugins/antianqi/mcode-island/scripts/smoke.mjs b/plugins/antianqi/mcode-island/scripts/smoke.mjs index 5123470a..48c8c26d 100644 --- a/plugins/antianqi/mcode-island/scripts/smoke.mjs +++ b/plugins/antianqi/mcode-island/scripts/smoke.mjs @@ -479,6 +479,21 @@ const main = async () => { } 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: the follow-up z-order SetWindowPos call (HWND_TOP + // to push WT forward without foreground permission) MUST carry + // SWP_NOSIZE. Without it, cx=0/cy=0 is interpreted as "resize + // to 0x0", triggering WT's min-size fallback to a 480x76 strip — + // exactly the regression the user saw. Verified empirically: + // a click via computer-use on the live widget left WT at 480x76 + // despite the work-area SetWindowPos having run a few ms earlier. + if (!/SWP_NOSIZE\s*=\s*0x0001/.test(widget)) { + out('FAIL', 'mcode-island.ps1: WinAPI class missing SWP_NOSIZE constant (0x0001).'); + } else if (!/SWP_NOZORDER\s*-bor\s*\[WinAPI\]::SWP_NOSIZE/.test(toggleBody)) { + out('FAIL', 'mcode-island.ps1: Toggle z-order SetWindowPos(HWND_TOP) does not include SWP_NOSIZE. cx=0/cy=0 will resize WT to 0x0 and trigger its 480x76 min-size fallback.'); + } else { + out('PASS', 'mcode-island.ps1: Toggle z-order SetWindowPos carries SWP_NOSIZE (won\'t trigger WT min-size 480x76 fallback)'); + } } } From ab363d2b232cac5bdb2a77064f19f32ffe26b0a2 Mon Sep 17 00:00:00 2001 From: antianqi Date: Sun, 27 Sep 2026 14:35:02 +0800 Subject: [PATCH 06/38] feat(mcode-island): tool-verb messages + family tinting (round-18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pill showed the bare tool name for every phase, so `working` and `done` were indistinguishable at a glance: both read "bash". The mcode CLI TUI solved this with a descriptor table (launcher-GHPADSKI.js HL[]) that gives every tool a present-tense verb while it is in flight and a past-tense one once it returns. This commit ports that idea to the pill. Detector - New $TOOL_ACTIONS: 18 tools x {running, done, fail}. Messages become "Running ..." / "Ran" / "Command failed" instead of "bash : {...}" / "bash 完成" / "bash 失败". - New $TOOL_FAMILIES: maps a tool to one of 7 families (shell/read/write/search/task/web/plan). - New Get-ToolKey / Get-ToolVerb / Get-ToolVerbFallback / Get-ToolFamily. Unknown tools fall back to "Using " / "Used " so the pill never shows a bare identifier with no phase signal. - Get-ToolKey caps the normalized name at 64 chars. The name comes from the session log, i.e. it is attacker-influenced in a hostile repo, and a 4 KB "tool name" must not become a hashtable key. - Infer-State now returns a `family` field on every tool branch, and Write-Status emits it to status.json. - $MSG_FAIL / $MSG_OK are removed. The three tool branches were their only consumers, and leaving dead byte-array constants behind would invite a future reader to think they are still wired up. Widget - New $familyMap: 7 families -> dot colors. - Update-State takes $Family and resolves a $dotHex / $ringHex. The family color overrides the state color for thinking/working/waiting only, so done stays green and error stays red -- the result signal the user relies on must not be repainted by whatever tool happened to run. - The status.json poll signature now includes `family`. Without it, two consecutive working states from different families would be deduped and the dot would keep the previous color. - The progress bar follows the same resolved color, so the dot, the pulse ring, and the bar always agree. Validation - smoke.mjs: 36 new checks in sections 5d/5e, covering the two tables, all four helpers, the three required families, the three state-branch call sites, the status.json field, the widget $Family parameter, the active-state gate, and the dot/ring paint path. - 98 pass, 7 warn, 0 fail locally. Test evidence - 16 negative-injection cases, each a minimal change that breaks one clause of the contract. All 16 turn the suite red; the baseline is restored to 98/0 after each. - Three of the 16 initially did NOT go red. They were false greens and the checks were rewritten rather than the failures being waved off: 1. The active-state gate was matched as a bare `$State -in @('thinking','working','waiting')`, but that substring also appears on the pulse, elapsed, and progress branches. Ungating the family tint left those three in place and the check stayed green. It now anchors on the `$Family -and $State` conjunction. 2. The three state-branch locks were presence tests, but each state has TWO call sites (the ledger.jsonl branch and the messages.jsonl branch). Breaking only the ledger branch left the messages branch matching. They now assert a minimum occurrence count of 2. 3. The first harness used CRLF anchors against LF-only files, so every case reported SETUP-MISS and the suite looked green while nothing had actually run. Anchors now use LF and the harness fails loudly on a missing anchor instead of counting it as a pass. - End-to-end on the live widget: all 6 active family colors render (shell green, read blue, write yellow, search purple, web pink, plan violet) and done/error keep their green/red result colors. - Live status.json after deploy carries the new contract end to end: {"state":"working","message":"Running {...}","family":"shell",...} Design compliance - No credentials, no network, no telemetry, no third-party services. The verb tables are static data; the only I/O is the pre-existing status.json write that the widget already polls. - Cross-platform: no host paths introduced. The tables are keyed by tool name only, and the existing hardcoded-path scan covers the edited files. - ASCII-only additions. The existing Chinese message constants are untouched, so the PS 5.1 GBK parsing defense is unchanged. - Session log is a local file the user already owns; reading tool names out of it adds no new capability and no new data source. --- .../antianqi/mcode-island/mcode-island.ps1 | 49 ++++-- .../mcode-island/mcode-status-detect.ps1 | 136 +++++++++++++-- .../antianqi/mcode-island/scripts/smoke.mjs | 155 ++++++++++++++++++ 3 files changed, 315 insertions(+), 25 deletions(-) diff --git a/plugins/antianqi/mcode-island/mcode-island.ps1 b/plugins/antianqi/mcode-island/mcode-island.ps1 index 4b7bbcb5..5f886d44 100644 --- a/plugins/antianqi/mcode-island/mcode-island.ps1 +++ b/plugins/antianqi/mcode-island/mcode-island.ps1 @@ -287,6 +287,19 @@ $stateMap = @{ error = @{ dot='#FFEF4444'; ring='#FFEF4444'; label='mcode · 出错'; icon='✕' } } +# 工具家族配色:working 态下用 family 色盖掉 state 色,一眼分辨"在读"和"在改"。 +# 只覆盖 dot/ring(视觉识别),不动 stateText 文案。 +# family 名与 detector 的 $TOOL_FAMILIES 值一一对应;缺 family 时回落到 state 色。 +$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 = '#FF8B5CF6' # todowrite → 紫罗兰 +} + # 颜色转 brush function C($hex) { return (New-Object System.Windows.Media.SolidColorBrush([System.Windows.Media.ColorConverter]::ConvertFromString($hex))) } @@ -453,12 +466,21 @@ function Update-State { [int]$TodoProgress = -2, [int]$Step = -1, [int]$Total = -1, - [string]$Detail = '' + [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 = Build-DisplayMessage -Message $Message -Step $Step -Total $Total -Detail $Detail $script:actionIcon.Text = $s.icon @@ -514,20 +536,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 { @@ -829,11 +851,14 @@ $timer.Add_Tick({ 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 } - $sig = "$($data.state)|$($data.message)|$prog|$usage|$resetMs|$todoP|$step|$total|$detail|$($data.ts)" + # 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) 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 + 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)" } @@ -859,9 +884,11 @@ if (Test-Path $statusFile) { 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 } - $script:lastStatusSig = "$($init.state)|$($init.message)|$initProg|$initUsage|$initReset|$initTodo|$initStep|$initTotal|$initDetail|$($init.ts)" + $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 -Step $initStep -Total $initTotal -Detail $initDetail + 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..d3c8f808 100644 --- a/plugins/antianqi/mcode-island/mcode-status-detect.ps1 +++ b/plugins/antianqi/mcode-island/mcode-status-detect.ps1 @@ -1,4 +1,4 @@ -# mcode-status-detect.ps1 - 后台守护进程 (v0.2.1) +# mcode-status-detect.ps1 - 后台守护进程 (v0.2.1) # 监听 mcode session log,实时推断 agent 状态,写到 status.json # 让 widget 不依赖 agent 主动调 notify-island.ps1 也能跟着动 # @@ -78,8 +78,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 +92,47 @@ $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' = 'task' + '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 @@ -327,6 +366,49 @@ 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 +} + function Infer-State($msg) { if (-not $msg) { return $null } # ledger.jsonl 事件格式:{kind, phase, action, ...} @@ -337,11 +419,23 @@ 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 + $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='' } @@ -370,13 +464,16 @@ function Infer-State($msg) { if ($hasTool) { $tool = $hasTool.name + $verb = Get-ToolVerb $tool 'running' + if (-not $verb) { $verb = Get-ToolVerbFallback $tool 'running' } $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" } + return @{ state=$S_WORKING; message="$verb $argsJson"; 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 +483,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,18 +498,21 @@ function Infer-State($msg) { return $null } -function Write-Status($state, $message) { +function Write-Status($state, $message, $family) { $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 } $payload = [PSCustomObject]@{ state = $state message = $message + family = $famField progress = -1 usage5h = $usageField usage5hResetMs = $resetField @@ -577,7 +683,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 +702,8 @@ 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 } + Write-Status $sForU $mForU $fForU Log-Line ("5h usage refreshed: remaining=" + $curPct + "% resetMs=" + $curMs) } @@ -609,7 +716,8 @@ 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 } + Write-Status $sForT $mForT $fForT $script:plan5hLastWrittenTodoPct = $curTodoPct if ($null -ne $curTodoData) { Log-Line ("todo refreshed: " + $curTodoData.completed + "/" + $curTodoData.total + " = " + $curTodoPct + "%") diff --git a/plugins/antianqi/mcode-island/scripts/smoke.mjs b/plugins/antianqi/mcode-island/scripts/smoke.mjs index 48c8c26d..57bf55c8 100644 --- a/plugins/antianqi/mcode-island/scripts/smoke.mjs +++ b/plugins/antianqi/mcode-island/scripts/smoke.mjs @@ -497,6 +497,161 @@ const main = async () => { } } + // 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', + })) { + 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)'); + } + } + + // 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'); + if (!/\$familyMap\s*=\s*@\{/.test(widget)) { + out('FAIL', 'mcode-island.ps1: $familyMap table missing'); + } else { + const famBlock = widget.match(/\$familyMap\s*=\s*@\{([\s\S]*?)\n\}/); + for (const fam of ['shell', 'read', 'write', 'search', 'task', 'web', 'plan']) { + if (famBlock && 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'); + } + } + // 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` From 862f5a18e1183b3b588b71f666ce192d337c17b3 Mon Sep 17 00:00:00 2001 From: antianqi Date: Sun, 27 Sep 2026 14:57:52 +0800 Subject: [PATCH 07/38] fix(mcode-island): separate plan/search hues, lock family color distance The round-18 family palette shipped with plan at #8B5CF6 against search's #A855F7. On the dark pill those two read as the same purple, so `grep` and `todowrite` were not actually distinguishable -- which is the whole reason the tinting exists. plan moves to a desaturated slate #94A3B8. smoke.mjs gains a perceptual-separation check over the family table. It converts each #AARRGGBB entry to CIELAB and asserts every pair is at least dE 20 apart (CIE76, the usual "not the same color to a human eye" cutoff for flat UI fills). Test evidence - Negative injection: putting plan back to #8B5CF6 makes the check fail with "search (#A855F7) and plan (#8B5CF6) are only dE 10.1 apart". Restoring #94A3B8 returns the suite to 100 pass. - The check itself went through two false greens before it was correct, both caught by writing the guard before trusting the output: 1. The color regex required 6 hex digits, but the table stores #AARRGGBB, so it matched nothing and the check reported "all 0 family colors are perceptually distinct" -- a vacuous pass. A parse-count guard now fails outright below 7 entries. 2. The same check initially measured raw sRGB luminance, which flags blue vs purple as "identical" (0.02 apart) because they differ in hue but not brightness. That produced five false alarms on a palette that is fine in practice. Replaced with CIELAB dE. - Live widget: all 7 family colors confirmed distinct, and the earlier screenshot run that showed six identical rows was a harness timing fault (capturing the previous frame), not a rendering fault -- the capture now waits for the widget to log the state before shooting. - 100 pass, 7 warn, 0 fail. --- .../antianqi/mcode-island/mcode-island.ps1 | 6 +- .../antianqi/mcode-island/scripts/smoke.mjs | 66 ++++++++++++++++++- 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/plugins/antianqi/mcode-island/mcode-island.ps1 b/plugins/antianqi/mcode-island/mcode-island.ps1 index 5f886d44..cd3eff75 100644 --- a/plugins/antianqi/mcode-island/mcode-island.ps1 +++ b/plugins/antianqi/mcode-island/mcode-island.ps1 @@ -290,6 +290,10 @@ $stateMap = @{ # 工具家族配色: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 → 蓝 @@ -297,7 +301,7 @@ $familyMap = @{ search = '#FFA855F7' # grep / glob → 紫 task = '#FF06B6D4' # task → 青 web = '#FFEC4899' # web_search/fetch → 粉 - plan = '#FF8B5CF6' # todowrite → 紫罗兰 + plan = '#FF94A3B8' # todowrite → 灰蓝 } # 颜色转 brush diff --git a/plugins/antianqi/mcode-island/scripts/smoke.mjs b/plugins/antianqi/mcode-island/scripts/smoke.mjs index 57bf55c8..8dcae84f 100644 --- a/plugins/antianqi/mcode-island/scripts/smoke.mjs +++ b/plugins/antianqi/mcode-island/scripts/smoke.mjs @@ -617,12 +617,15 @@ const main = async () => { out('FAIL', 'mcode-island.ps1 missing (family-tint drift lock skipped)'); } else { const widget = await readFile(widgetPath, 'utf8'); - if (!/\$familyMap\s*=\s*@\{/.test(widget)) { + // 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 { - const famBlock = widget.match(/\$familyMap\s*=\s*@\{([\s\S]*?)\n\}/); for (const fam of ['shell', 'read', 'write', 'search', 'task', 'web', 'plan']) { - if (famBlock && new RegExp(`^\\s*${fam}\\s*=`, 'm').test(famBlock[1])) { + if (new RegExp(`^\\s*${fam}\\s*=`, 'm').test(famBlock[1])) { out('PASS', `$familyMap covers "${fam}"`); } else { out('FAIL', `$familyMap does not cover "${fam}"`); @@ -650,6 +653,63 @@ const main = async () => { } 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"}`, From 012628ffa45675350845f5aa89f66d994d887a60 Mon Sep 17 00:00:00 2001 From: antianqi Date: Sun, 27 Sep 2026 15:09:27 +0800 Subject: [PATCH 08/38] feat(mcode-island): ask_user reports waiting, not working MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a `full access` permission setup the permission-request hook never fires, so `waiting` was unreachable and ask_user was the only thing that could actually block the agent. It was reporting `working`: working :: ask_user : {"mode":"questionnaire","requiresExplicitResponse":true,... The pill showed a blue dot with a gear icon -- the universal "leave it alone, it is making progress" signal -- while the agent sat blocked on a questionnaire, plus a truncated JSON blob where the useful text should be. Three occurrences in the 9/24 log confirm this was not a one-off. ask_user now reports `waiting` with a question count, so the pill reads "Asking 2 questions" under an amber dot and a "?" -- the "come back, I'm blocked" signal. It wears the state color rather than a family tint: a blocked agent must not look like an in-flight delegation. Detector changes - $S_WAITING is now defined. It was referenced nowhere before, so any branch using it would have returned $null for the state. - Test-IsAskUser and Get-AskQuestionCount helpers, wired into both tool paths (ledger.jsonl and messages.jsonl). - waiting joins 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 the pre-tool-use hook's `working` shadow it forever. - ask_user is removed from $TOOL_FAMILIES. Bug found while testing - Get-AskQuestionCount originally took a parameter named `$args`, which is a PowerShell automatic variable. Shadowing it makes every read inside the function return the function's own argument list instead of the caller's value, so the count was 0 for every input and the message degraded to a bare "Asking". Caught by exercising the helper against seven argument shapes before wiring it up; the parameter is now $toolArgs and smoke locks the name. Validation - smoke.mjs: 7 new checks in section 5d2. 107 pass, 7 warn, 0 fail. - 7 negative-injection cases, all turn the suite red; baseline restores to 107/0 after each. Two of them target the multi-site and settle-set contracts specifically, since those are the ones that fail silently. - The real Infer-State from the deployed file was exercised against nine real messages.jsonl payload shapes: ask_user with 0/1/2/5 questions, a missing questions key, absent arguments, and arguments arriving as a JSON string. All six ask_user shapes infer `waiting` with no family; bash, read, and todowrite are unchanged. - Live widget: `Asking 2 questions` renders amber with a `?`, and the post-answer transition to `done` / `Asked` still works. Test evidence - Deliberately did NOT append synthetic toolCall records to a live session log. The running agent is writing to that file and injecting phantom work into it could make the agent act on it. The end-to-end check runs the deployed Infer-State over real payload shapes instead, which covers the same code path without touching agent state. Design compliance - No new I/O, no network, no credentials, no telemetry. The change is a state mapping plus a question count read from arguments the detector was already parsing. - Get-AskQuestionCount swallows parse failures and returns 0, so a malformed arguments payload degrades to "Asking" rather than breaking the poll loop. - ASCII-only additions; the PS 5.1 GBK parsing defense is unchanged. --- .../mcode-island/mcode-status-detect.ps1 | 59 ++++++++++++++-- .../antianqi/mcode-island/scripts/smoke.mjs | 70 +++++++++++++++++++ 2 files changed, 125 insertions(+), 4 deletions(-) diff --git a/plugins/antianqi/mcode-island/mcode-status-detect.ps1 b/plugins/antianqi/mcode-island/mcode-status-detect.ps1 index d3c8f808..831bddee 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 @@ -125,7 +126,11 @@ $TOOL_FAMILIES = @{ 'read' = 'read' 'edit' = 'write'; 'write' = 'write' 'grep' = 'search'; 'search' = 'search'; 'glob' = 'search'; 'list' = 'search' - 'task' = 'task'; 'task_output' = 'task'; 'task_append' = 'task'; 'ask_user' = 'task' + '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' @@ -409,6 +414,35 @@ function Get-ToolFamily($name) { return $null } +# 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, ...} @@ -431,6 +465,11 @@ function Infer-State($msg) { } if ($kind -eq 'toolCall' -or $kind -eq $S_TOOLCALL) { $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 @@ -464,9 +503,14 @@ function Infer-State($msg) { if ($hasTool) { $tool = $hasTool.name + $args = $hasTool.arguments + 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' } - $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 @@ -643,9 +687,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) { @@ -663,7 +710,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 = '' diff --git a/plugins/antianqi/mcode-island/scripts/smoke.mjs b/plugins/antianqi/mcode-island/scripts/smoke.mjs index 8dcae84f..e261c686 100644 --- a/plugins/antianqi/mcode-island/scripts/smoke.mjs +++ b/plugins/antianqi/mcode-island/scripts/smoke.mjs @@ -567,6 +567,10 @@ const main = async () => { 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}"`); @@ -607,6 +611,72 @@ const main = async () => { } 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 From 1daee4bcccac3aec5fe4b9c8b1f9b6d7c0eb92b7 Mon Sep 17 00:00:00 2001 From: antianqi Date: Sun, 27 Sep 2026 15:31:49 +0800 Subject: [PATCH 09/38] fix(mcode-island): redact typed text in tool summaries (round-19 #1) Security. Format-ToolSummary put `tool_input.text` -- whatever the user typed -- straight into the pill detail string. That string reaches: - status.json on disk - island.log, append-only - the always-on-top pill, visible to anything sharing the screen So a password, one-time code, or token entered through Computer Use ended up in a screenshot, a screen share, or a screen recording. The pill is the worst of the three because it is deliberately on top of everything else. The text branch now reports the action plus a character count and nothing else. No substring, no length bucketing that would leak the shape of the value, no echo. The action name survives, so the pill still answers the question that matters: is the agent typing right now? The coordinate branch is untouched -- coordinates are not secret and are genuinely useful to see. Validation - scripts/test-substep-progress.mjs is rewritten as a behavioral suite (see the CI commit), and section 1 plants a canary secret SUPER-SECRET-PASSWORD-8f3a91c2 in tool_input.text, runs the real Format-ToolSummary, and asserts the canary is absent from the output. It also asserts the action survives and the length is reported, so a redaction implemented by silently dropping the branch would not pass. - End to end against the deployed hook: a PreToolUse event carrying P@ssw0rd-CANARY-7d3e91 produced status.json: "mcode-computer-use : type <22 chars, redacted>" island.log: "working :: mcode-computer-use : type <22 chars, redacted>" and the canary appears in neither. - Negative injection: restoring the original `$detail = "$act '$txt'"` turns the canary check red. Design compliance - No new I/O or network. The change only removes data from a string. --- .../io.minimax.mcode/hooks/scripts/_lib.ps1 | 19 +- .../scripts/test-substep-progress.mjs | 658 +++++++++--------- 2 files changed, 346 insertions(+), 331 deletions(-) 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 0990f5b4..9db91abe 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 @@ -100,13 +100,22 @@ 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/text + # mcode-internal: Computer Use 抽 action + coordinate。 # 例: "mcode-computer-use : click at (1024,768)" - # "mcode-computer-use : type 'hello'" # 注意: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) { @@ -114,8 +123,10 @@ function Format-ToolSummary { $coordStr = "($($coord -join ','))" $detail = "$act at $coordStr" } elseif ($Event.tool_input.text) { - $txt = [string]$Event.tool_input.text - $detail = "$act '$txt'" + # 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 } diff --git a/plugins/antianqi/mcode-island/scripts/test-substep-progress.mjs b/plugins/antianqi/mcode-island/scripts/test-substep-progress.mjs index 8e54f567..5a908ee2 100644 --- a/plugins/antianqi/mcode-island/scripts/test-substep-progress.mjs +++ b/plugins/antianqi/mcode-island/scripts/test-substep-progress.mjs @@ -1,387 +1,391 @@ -#!/usr/bin/env node -// mcode-island v0.4.x — negative-first self-check for the sub-step -// progress extension (status.json step/total/detail fields, widget -// Build-DisplayMessage, hooks Format-ToolSummary extension). +// test-substep-progress.mjs — behavioral tests for the sub-step progress +// contract and the summary redaction rules. // -// Cross-platform (Windows / macOS / Linux). No deps beyond Node >= 18. +// 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. // -// Exit 0 on full pass, 1 on any failure. Per-check line prints -// PASS / FAIL with the failing expectation. +// node scripts/test-substep-progress.mjs // -// Test design (per mcode-island round-4 lesson): -// For every contract, the test MUST be able to fail when the -// implementation regresses. We achieve this by: -// 1. Asserting the *observable* contract (status.json shape, -// PowerShell function output) not the internal call graph -// 2. Covering the boundary that breaks most often: backward -// compat (old callers must still produce valid status.json) - -import { spawn } from 'node:child_process'; -import { mkdir, writeFile, readFile, rm, stat } from 'node:fs/promises'; +// Exit 0 = all passed. Any failure exits 1. + +import { readFileSync, existsSync, mkdtempSync, writeFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join, resolve, dirname } from 'node:path'; +import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { strict as assert } from 'node:assert'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const PLUGIN_ROOT = resolve(__dirname, '..'); -const NOTIFY_ISLAND = join(PLUGIN_ROOT, 'notify-island.ps1'); -const WIDGET = join(PLUGIN_ROOT, 'mcode-island.ps1'); -const LIB = join(PLUGIN_ROOT, 'io.minimax.mcode', 'hooks', 'scripts', '_lib.ps1'); - -// Cross-platform pwsh lookup: prefer pwsh7 install in $HOME, fall back -// to PATH-resolved `pwsh`. We do NOT hardcode C:\ paths in production -// (the smoke.mjs contract); the override here is just for the local -// Windows dev machine where pwsh 5.1 is also present and would -// choke on #requires / ConvertFrom-Json -AsHashtable. -function findPwsh() { - if (process.platform === 'win32') { - const home = process.env.USERPROFILE || process.env.HOME || ''; - const candidates = [ - join(home, 'pwsh7_6', 'pwsh.exe'), - join(home, 'pwsh', 'pwsh.exe'), - ]; - return candidates[0]; // best-effort; if missing, spawn falls back to PATH - } - return 'pwsh'; -} +import { execFileSync } from 'node:child_process'; -const PWSH = findPwsh(); - -let pass = 0, fail = 0; -const out = (tag, msg) => { - const sym = { PASS: 'OK ', FAIL: 'FAIL' }[tag]; - console.log(`[${sym}] ${msg}`); - if (tag === 'PASS') pass++; else fail++; -}; - -const exists = async (p) => { - try { await stat(p); return true; } catch { return false; } -}; - -// Spawn pwsh with a custom $env:APPDATA so we don't disturb the -// real %APPDATA%\mcode-island\ the widget is reading. -async function runPwsh(script, extraEnv = {}) { - const tmpAppData = join(tmpdir(), `mcode-island-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`); - await mkdir(join(tmpAppData, 'mcode-island'), { recursive: true }); - return new Promise((resolveP, rejectP) => { - const child = spawn(PWSH, ['-NoProfile', '-Command', script], { - env: { - ...process.env, - APPDATA: tmpAppData, - ...extraEnv, - }, - }); - let stdout = '', stderr = ''; - child.stdout.on('data', (d) => stdout += d); - child.stderr.on('data', (d) => stderr += d); - child.on('close', (code) => resolveP({ stdout, stderr, code, tmpAppData })); - child.on('error', rejectP); - }); -} +const HERE = dirname(fileURLToPath(import.meta.url)); +const PLUGIN_ROOT = dirname(HERE); -async function readStatusJsonOf(tmpAppData) { - // notify-island.ps1 writes via [System.IO.File]::WriteAllText(.., UTF8). - // On Windows PowerShell 5.1 / .NET Framework that emits a UTF-8 BOM - // (\ufeff). PowerShell's Get-Content -Encoding UTF8 strips it, but - // Node's strict JSON.parse doesn't. We strip here so the test sees - // what the widget sees. - let raw = await readFile(join(tmpAppData, 'mcode-island', 'status.json'), 'utf8'); - if (raw.charCodeAt(0) === 0xFEFF) raw = raw.slice(1); - return raw; +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}`); } // --------------------------------------------------------------------------- -// 1. notify-island.ps1 schema — round-trip +// PowerShell harness // --------------------------------------------------------------------------- -async function testNotifySchema() { - console.log('-'.repeat(60)); - console.log('1. notify-island.ps1 schema round-trip'); - - // 1a. all three new fields - { - const r = await runPwsh(`& "${NOTIFY_ISLAND}" -State working -Message 'X' -Step 3 -Total 12 -Detail 'fill username'`); - assert.equal(r.code, 0, `notify-island exited ${r.code}: ${r.stderr}`); - const j = JSON.parse(await readStatusJsonOf(r.tmpAppData)); - assert.equal(j.step, 3, 'step must round-trip'); - assert.equal(j.total, 12, 'total must round-trip'); - assert.equal(j.detail, 'fill username', 'detail must round-trip'); - out('PASS', 'all three new fields round-trip with explicit values'); - } +const PS = process.env.PS_BIN || (process.platform === 'win32' ? 'powershell.exe' : 'pwsh'); - // 1b. step without total - { - const r = await runPwsh(`& "${NOTIFY_ISLAND}" -State working -Message 'X' -Step 5 -Detail 'npm install'`); - const j = JSON.parse(await readStatusJsonOf(r.tmpAppData)); - assert.equal(j.step, 5); - assert.equal(j.total, -1, 'total stays at -1 when caller omits it'); - assert.equal(j.detail, 'npm install'); - out('PASS', 'step without total: step=5, total stays -1'); +// 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; +} - // 1c. backward compat — old callers don't pass new params - { - const r = await runPwsh(`& "${NOTIFY_ISLAND}" -State working -Message 'legacy call'`); - const j = JSON.parse(await readStatusJsonOf(r.tmpAppData)); - assert.equal(j.step, -1, 'backward compat: step must default to -1'); - assert.equal(j.total, -1, 'backward compat: total must default to -1'); - assert.equal(j.detail, '', 'backward compat: detail must default to empty string'); - assert.equal(j.message, 'legacy call', 'message preserved'); - assert.equal(j.state, 'working'); - // Negative-injection for this contract: imagine the - // implementation forgot to write step/total/detail at all — - // the JSON would lack these fields, and `j.step` would be - // `undefined`. The assert.equal above against -1 catches that. - out('PASS', 'backward compat: old callers produce step=-1, total=-1, detail=""'); +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'); +} - // 1d. schema version invariants — these were already there - { - const r = await runPwsh(`& "${NOTIFY_ISLAND}" -State working -Message 'X'`); - const j = JSON.parse(await readStatusJsonOf(r.tmpAppData)); - for (const k of ['state', 'message', 'progress', 'ts', 'source']) { - assert.ok(k in j, `existing field ${k} must still be present`); +/** 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); } - out('PASS', 'existing fields (state/message/progress/ts/source) preserved'); } + 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; } // --------------------------------------------------------------------------- -// 2. Build-DisplayMessage — PowerShell function unit +// 1. Secret redaction (round-19 review #1) // --------------------------------------------------------------------------- -async function testBuildDisplayMessage() { - console.log('-'.repeat(60)); - console.log('2. Build-DisplayMessage function contract'); - - // Source-out the function from mcode-island.ps1 by sourcing it in - // a no-window context, then asserting. mcode-island.ps1 starts WPF - // if you load it directly, so we extract just the function body - // by `Get-Content | Select-String` and re-define it for testing. - const widgetSrc = await readFile(WIDGET, 'utf8'); - const fnMatch = widgetSrc.match(/function Build-DisplayMessage\s*\{[\s\S]*?\n\}/); - assert.ok(fnMatch, 'Build-DisplayMessage function must exist in widget source'); - const fnBody = fnMatch[0]; +// 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'); + + let formatToolSummary; + try { + formatToolSummary = extractFn(lib, 'Format-ToolSummary'); + } catch (e) { + bad('Format-ToolSummary is extractable', e.message); + } - const cases = [ - // [step, total, detail, message, expected] - [3, 12, 'fill username', 'Bash ok', 'step 3/12 · fill username'], - [5, -1, 'npm install', 'Bash', 'step 5 · npm install'], - [3, 12, '', 'Bash ok', 'step 3/12'], - [3, -1, '', 'Bash ok', 'step 3'], - [-1, -1, '', 'Bash ok', 'Bash ok'], - [-1, -1, 'orphan', 'Bash ok', 'Bash ok'], // no step → ignore detail - [3, 0, 'edge', 'Bash ok', 'step 3 · edge'], // total=0 → no /N - [3, 12, 'detail wins', '', 'step 3/12 · detail wins'], // empty message - [3, 12, 'd', 'm', 'step 3/12 · d'], // detail replaces message - ]; + if (formatToolSummary) { + ok('Format-ToolSummary is extractable'); - for (const [step, total, detail, message, expected] of cases) { - const script = ` -${fnBody} -$r = Build-DisplayMessage -Message ${JSON.stringify(message)} -Step ${step} -Total ${total} -Detail ${JSON.stringify(detail)} -Write-Output $r + const harness = ` +$ErrorActionPreference = 'Stop' +${formatToolSummary} +$evt = [PSCustomObject]@{ + tool_name = 'mcode-computer-use' + tool_input = [PSCustomObject]@{ action = 'type'; coordinate = $null; text = '${CANARY}' } +} +Format-ToolSummary $evt `; - const r = await runPwsh(script); - const got = r.stdout.trim(); - if (got === expected) { - out('PASS', `Build-DisplayMessage(${step},${total},"${detail}","${message}") = "${expected}"`); - } else { - out('FAIL', `Build-DisplayMessage(${step},${total},"${detail}","${message}"): expected "${expected}", got "${got}"`); - } + 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' +${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())}`); } } // --------------------------------------------------------------------------- -// 3. Format-ToolSummary — mcode-computer-use case +// 2. Sub-step rendering (round-19 review #4) // --------------------------------------------------------------------------- -async function testFormatToolSummaryCU() { - console.log('-'.repeat(60)); - console.log('3. Format-ToolSummary for mcode-computer-use'); - - const libSrc = await readFile(LIB, 'utf8'); - const fnMatch = libSrc.match(/function Format-ToolSummary\s*\{[\s\S]*?\n\}/); - assert.ok(fnMatch, 'Format-ToolSummary must exist in _lib.ps1'); - const fnBody = fnMatch[0]; +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 = [ - // input event JSON, expected output - [{ tool_name: 'mcode-computer-use', tool_input: { action: 'click', coordinate: [1024, 768] } }, - 'mcode-computer-use : click at (1024,768)'], - [{ tool_name: 'mcode-computer-use', tool_input: { action: 'type', text: 'hello' } }, - 'mcode-computer-use : type \'hello\''], - [{ tool_name: 'mcode-computer-use', tool_input: { action: 'screenshot' } }, - 'mcode-computer-use : screenshot'], - [{ tool_name: 'mcode-computer-use', tool_input: { action: 'scroll', coordinate: [100, 200] } }, - 'mcode-computer-use : scroll at (100,200)'], - // existing tools — make sure we didn't break them - [{ tool_name: 'Bash', tool_input: { command: 'ls -la /tmp' } }, - 'Bash : ls -la /tmp'], - [{ tool_name: 'Read', tool_input: { file_path: 'C:/foo/bar.txt' } }, - 'Read : C:/foo/bar.txt'], - [{ tool_name: 'Edit', tool_input: { file_path: 'C:/baz/qux.ts' } }, - 'Edit : C:/baz/qux.ts'], + // [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 [evt, expected] of cases) { - const script = ` -${fnBody} -$evt = '${JSON.stringify(evt).replace(/'/g, "''")}' | ConvertFrom-Json -$r = Format-ToolSummary $evt -Write-Output $r + 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 r = await runPwsh(script); - const got = r.stdout.trim(); - if (got === expected) { - out('PASS', `Format-ToolSummary(${JSON.stringify(evt.tool_name)}) = "${expected}"`); - } else { - out('FAIL', `Format-ToolSummary(${JSON.stringify(evt.tool_name)}): expected "${expected}", got "${got}"`); - } + const out = runPs(harness).trim(); + eq(name, out, expected); } } -// --------------------------------------------------------------------------- -// 4. Push-Island wrapper signature — hook can pass new params -// --------------------------------------------------------------------------- - -async function testPushIslandSignature() { - console.log('-'.repeat(60)); - console.log('4. Push-Island accepts new params'); - - const libSrc = await readFile(LIB, 'utf8'); - // Sanity: Push-Island declares Step/Total/Detail as named params. - const pushMatch = libSrc.match(/function Push-Island\s*\{[\s\S]*?\n\}/); - assert.ok(pushMatch, 'Push-Island must exist in _lib.ps1'); - const fnBody = pushMatch[0]; - if (!/\[int\]\$Step\s*=\s*-1/.test(fnBody)) { - out('FAIL', 'Push-Island: missing `[int]$Step = -1` param'); - } else { - out('PASS', 'Push-Island: declares [int]$Step = -1'); - } - if (!/\[int\]\$Total\s*=\s*-1/.test(fnBody)) { - out('FAIL', 'Push-Island: missing `[int]$Total = -1` param'); - } else { - out('PASS', 'Push-Island: declares [int]$Total = -1'); - } - if (!/\[string\]\$Detail\s*=\s*''/.test(fnBody)) { - out('FAIL', 'Push-Island: missing `[string]$Detail = \'\'` param'); - } else { - out('PASS', 'Push-Island: declares [string]$Detail = \'\''); - } - // And the call to notify-island.ps1 inside Push-Island must forward them - // The forward is via `$script:NotifyIsland` (a variable), not a literal - // "notify-island.ps1" string, so the regex anchors on the args. - if (!/-State\s+\$State\s+-Message\s+\$Message\s+-Step\s+\$Step\s+-Total\s+\$Total\s+-Detail\s+\$Detail/s.test(fnBody)) { - out('FAIL', 'Push-Island: does not forward -Step -Total -Detail to notify-island.ps1'); - } else { - out('PASS', 'Push-Island: forwards Step/Total/Detail to notify-island.ps1'); +// 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'); } // --------------------------------------------------------------------------- -// main +// 3. Detector preserves sub-step fields across a rewrite (review #2) // --------------------------------------------------------------------------- -// --------------------------------------------------------------------------- -// 5. Push-Island → notify-island.ps1 → status.json integration -// --------------------------------------------------------------------------- +// 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'); -async function testPushIslandIntegration() { - console.log('-'.repeat(60)); - console.log('5. Push-Island end-to-end (hook → status.json)'); + const writeStatusFn = extractFn(detect, 'Write-Status'); - const libSrc = await readFile(LIB, 'utf8'); - // Strip the Push-Island function declaration and dot-source it. - // _lib.ps1 also runs `Set-ConsoleUtf8` at load which is harmless - // for testing, but we need to skip the initial doc-comment / errors. - const r = await runPwsh(` -$ErrorActionPreference = 'Stop' -. '${LIB.replace(/\\/g, '\\\\')}' -Push-Island -State working -Message 'CU' -Step 3 -Total 12 -Detail 'fill username' -Get-Content (Join-Path $env:APPDATA 'mcode-island/status.json') -Raw -`); - if (r.code !== 0) { - out('FAIL', `Push-Island script exited ${r.code}: ${r.stderr}`); - return; - } - // Strip BOM if present - let raw = r.stdout; - if (raw.charCodeAt(0) === 0xFEFF) raw = raw.slice(1); - const j = JSON.parse(raw); - if (j.step === 3 && j.total === 12 && j.detail === 'fill username') { - out('PASS', `Push-Island end-to-end: status.json has step=3, total=12, detail="fill username"`); + // Extract just the sub-step preservation lines as a standalone repro of + // the logic, so the test does not need the detector's whole environment. + const preserveMatch = writeStatusFn.match( + /\$prev = Read-StatusObj[\s\S]*?\$detailField = ''[\s\S]*?if \(\$prev\)\s*\{[\s\S]*?\n\s*\}/); + + if (!preserveMatch) { + bad('Write-Status preserves sub-step fields', + 'could not find the step/total/detail read-modify-write block in Write-Status'); } else { - out('FAIL', `Push-Island end-to-end: status.json has step=${j.step}, total=${j.total}, detail="${j.detail}"`); - } + ok('Write-Status preserves sub-step fields'); - // Backward compat: Push-Island without new params must still work - const r2 = await runPwsh(` + const harness = ` $ErrorActionPreference = 'Stop' -. '${LIB.replace(/\\/g, '\\\\')}' -Push-Island -State working -Message 'Bash ok' -Get-Content (Join-Path $env:APPDATA 'mcode-island/status.json') -Raw -`); - let raw2 = r2.stdout; - if (raw2.charCodeAt(0) === 0xFEFF) raw2 = raw2.slice(1); - const j2 = JSON.parse(raw2); - if (j2.step === -1 && j2.total === -1 && j2.detail === '' && j2.message === 'Bash ok') { - out('PASS', `Push-Island backward compat: missing new params → step=-1, total=-1, detail=""`); - } else { - out('FAIL', `Push-Island backward compat: status.json has step=${j2.step}, total=${j2.total}, detail="${j2.detail}"`); +$statusFile = $env:ISLAND_TEST_STATUS +function Read-StatusObj { + if (!(Test-Path $statusFile)) { return $null } + try { return ([System.IO.File]::ReadAllText($statusFile) | ConvertFrom-Json) } catch { return $null } +} +$prev = Read-StatusObj +$stepField = -1 +$totalField = -1 +$detailField = '' +${preserveMatch[0].split('\n').slice(1).join('\n')} +"$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|'); } } -async function main() { - console.log(`mcode-island sub-step progress self-check`); - console.log(`plugin root: ${PLUGIN_ROOT}`); - console.log(`pwsh: ${PWSH}`); - console.log('-'.repeat(60)); - - // Platform guard: this test exercises notify-island.ps1 which is - // a Windows-only plugin (it shells out to `chcp 65001` and uses - // Win32 console APIs). On Linux/macOS CI the notify-island.ps1 - // child fails before any assertion runs, exit code 1 → false - // green. The contract being tested (sub-step status.json schema, - // Build-DisplayMessage rendering, Format-ToolSummary for - // mcode-computer-use) is platform-agnostic, but the *fixture* for - // most cases is notify-island.ps1 itself, which is Windows-only. - // Smoke.mjs has the same Windows-bound gates and is expected to be - // invoked from the Windows-latest CI job, not from the - // ubuntu-latest `validate` step that runs `node --test`. Skip here. - if (process.platform !== 'win32') { - console.log('-'.repeat(60)); - console.log('SKIP: sub-step tests require Windows PowerShell (notify-island.ps1 is Windows-only).'); - console.log(` This test runs from the windows-latest job in .github/workflows/mcode-island-windows.yml.`); - process.exit(0); - } +// --------------------------------------------------------------------------- +// 4. SWP_NOMOVE on the restore path (review #3) +// --------------------------------------------------------------------------- - if (!(await exists(NOTIFY_ISLAND))) { - console.log(`[FAIL] notify-island.ps1 not found at ${NOTIFY_ISLAND}`); - process.exit(1); - } - if (!(await exists(WIDGET))) { - console.log(`[FAIL] mcode-island.ps1 not found at ${WIDGET}`); - process.exit(1); - } - if (!(await exists(LIB))) { - console.log(`[FAIL] _lib.ps1 not found at ${LIB}`); - process.exit(1); +{ + 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'); } +} - await testNotifySchema(); - await testBuildDisplayMessage(); - await testFormatToolSummaryCU(); - await testPushIslandSignature(); - await testPushIslandIntegration(); +// --------------------------------------------------------------------------- +// summary +// --------------------------------------------------------------------------- - console.log('-'.repeat(60)); - console.log(`summary: ${pass} pass, ${fail} fail`); - process.exit(fail > 0 ? 1 : 0); -} +try { rmSync(TMP, { recursive: true, force: true }); } catch { /* best effort */ } -main().catch((e) => { - console.error('FATAL:', e); - process.exit(2); -}); \ No newline at end of file +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); +} From 6fe166523258f39da5e5c6920d5d98bdf8d158cc Mon Sep 17 00:00:00 2001 From: antianqi Date: Sun, 27 Sep 2026 15:31:57 +0800 Subject: [PATCH 10/38] fix(mcode-island): detector preserves sub-step fields across a rewrite (round-19 #2) Write-Status rebuilds the whole status.json payload on every write, and the detector writes on every 5h-usage refresh and every todo-progress refresh. step / total / detail are agent-pushed sub-step state, not detector state, and they were simply not in the payload -- so a routine usage refresh silently zeroed whatever sub-step the agent had reported. On a long task the pill would drop "step 4/12" back to the bare message and never recover until the next tool call. Write-Status now reads the current file and carries step / total / detail forward. It is a read-modify-write, not an overwrite, and defaults to -1 / -1 / "" when there is no prior value so a fresh install still renders normally. Validation - Behavioral: a status.json carrying step=4 total=12 detail="running tests" survives a Write-Status rewrite with all three intact; an unwritten status.json yields -1 / -1 / "". - End to end against the deployed detector: notify-island pushed step=4 total=12 detail="running the test suite" (source=agent), then 15s later the detector wrote its own status and all three fields were still there. - Negative injection: reverting Write-Status to the blind overwrite turns the preservation check red. --- .../mcode-island/mcode-status-detect.ps1 | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/plugins/antianqi/mcode-island/mcode-status-detect.ps1 b/plugins/antianqi/mcode-island/mcode-status-detect.ps1 index 831bddee..f07b3aed 100644 --- a/plugins/antianqi/mcode-island/mcode-status-detect.ps1 +++ b/plugins/antianqi/mcode-island/mcode-status-detect.ps1 @@ -553,6 +553,19 @@ function Write-Status($state, $message, $family) { $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 每次刷 5h 用量或 todo 进度都会重写整个 payload,如果这里 + # 不把现有值读回来合并,正常使用中的 sub-step 进度会被静默清零 + # (round-19 review hetaoBackend #2)。读-改-写,不是覆盖。 + $prev = Read-StatusObj + $stepField = -1 + $totalField = -1 + $detailField = '' + 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 } + } $payload = [PSCustomObject]@{ state = $state message = $message @@ -562,6 +575,9 @@ function Write-Status($state, $message, $family) { usage5hResetMs = $resetField todoProgress = $todoPct todosCount = $todoCnt + step = $stepField + total = $totalField + detail = $detailField ts = (Get-Date).ToString($FMT_O) source = $S_DETECTOR } | ConvertTo-Json -Compress From 975fc86a10025e9b1480dfda6a286dd9bc9fefb0 Mon Sep 17 00:00:00 2001 From: antianqi Date: Sun, 27 Sep 2026 15:32:05 +0800 Subject: [PATCH 11/38] fix(mcode-island): restore path needs SWP_NOMOVE, and drops SWP_NOZORDER (round-19 #3) The z-order SetWindowPos after a restore used SWP_NOACTIVATE | SWP_NOZORDER | SWP_NOSIZE with X=Y=cx=cy=0. Two problems: SWP_NOMOVE is missing. X=0/Y=0 is then a real move request, so on any monitor whose origin is not 0 (a secondary display to the right, or above) the restored window is yanked to the primary monitor's (0,0). Invisible on a single primary display, which is why the round-15/16/17 manual testing never caught it. SWP_NOZORDER is present, which contradicts the call's own purpose. That flag makes Windows ignore hWndInsertAfter entirely, so passing HWND_TOP alongside it is self-defeating: the z-order change silently does nothing and only BringWindowToTop afterwards is doing the work. Now: SWP_NOACTIVATE | SWP_NOSIZE | SWP_NOMOVE, with HWND_TOP actually honored. The round-17 smoke lock asserted the literal `SWP_NOZORDER -bor SWP_NOSIZE`, i.e. exactly the pair the review asks to change, so it had to be rewritten rather than updated. It now checks that both required flags are present and that SWP_NOZORDER is absent, matching on flag names rather than their order. Validation - test-substep-progress.mjs and smoke.mjs both cover this. - Negative injection: dropping SWP_NOMOVE turns both red; adding SWP_NOZORDER back turns both red. --- .../antianqi/mcode-island/mcode-island.ps1 | 10 ++- .../antianqi/mcode-island/scripts/smoke.mjs | 62 ++++++++++++++++--- 2 files changed, 59 insertions(+), 13 deletions(-) diff --git a/plugins/antianqi/mcode-island/mcode-island.ps1 b/plugins/antianqi/mcode-island/mcode-island.ps1 index cd3eff75..93955eab 100644 --- a/plugins/antianqi/mcode-island/mcode-island.ps1 +++ b/plugins/antianqi/mcode-island/mcode-island.ps1 @@ -695,10 +695,14 @@ function Toggle-CallerWindow { 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 权限) - # 注意:cx=0/cy=0 + 缺 SWP_NOSIZE 会被 Windows 当成"resize 到 0x0", - # 触发 WT 的 min-size 兜底,变成 480x76 strip。必须加 SWP_NOSIZE。 + # 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_NOZORDER -bor [WinAPI]::SWP_NOSIZE + $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 diff --git a/plugins/antianqi/mcode-island/scripts/smoke.mjs b/plugins/antianqi/mcode-island/scripts/smoke.mjs index e261c686..53af2bf3 100644 --- a/plugins/antianqi/mcode-island/scripts/smoke.mjs +++ b/plugins/antianqi/mcode-island/scripts/smoke.mjs @@ -480,19 +480,61 @@ const main = async () => { 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: the follow-up z-order SetWindowPos call (HWND_TOP - // to push WT forward without foreground permission) MUST carry - // SWP_NOSIZE. Without it, cx=0/cy=0 is interpreted as "resize - // to 0x0", triggering WT's min-size fallback to a 480x76 strip — - // exactly the regression the user saw. Verified empirically: - // a click via computer-use on the live widget left WT at 480x76 - // despite the work-area SetWindowPos having run a few ms earlier. + // 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_NOZORDER\s*-bor\s*\[WinAPI\]::SWP_NOSIZE/.test(toggleBody)) { - out('FAIL', 'mcode-island.ps1: Toggle z-order SetWindowPos(HWND_TOP) does not include SWP_NOSIZE. cx=0/cy=0 will resize WT to 0x0 and trigger its 480x76 min-size fallback.'); + } else if (!/SWP_NOMOVE\s*=\s*0x0002/.test(widget)) { + out('FAIL', 'mcode-island.ps1: WinAPI class missing SWP_NOMOVE constant (0x0002).'); } else { - out('PASS', 'mcode-island.ps1: Toggle z-order SetWindowPos carries SWP_NOSIZE (won\'t trigger WT min-size 480x76 fallback)'); + // 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)'); + } } } } From 682f99916df5b29cd23868872bb1df59fa7ecea8 Mon Sep 17 00:00:00 2001 From: antianqi Date: Sun, 27 Sep 2026 15:32:14 +0800 Subject: [PATCH 12/38] fix(mcode-island): post-tool-use passes Step/Total so detail renders (round-19 #4) post-tool-use.ps1 computed a per-tool detail string and passed it as -Detail, but left Step at its -1 default. Build-DisplayMessage returns the bare message when Step <= 0 and only consults Detail inside the Step > 0 branch, so the detail was discarded on every single call. The "step N/M - detail" contract the widget documents could never fire from this hook. Both Push-Island calls now pass -Step 1 -Total 1. PostToolUse is a single tool result and does not participate in a multi-step sequence, so 1/1 is the honest value; inventing a larger denominator would risk rendering "step 1/0" on a fresh install. Validation - Build-DisplayMessage is exercised directly over five argument shapes: step+total+detail, step without total, step with empty detail, no step (detail must be ignored), and no step and no message. - Both Push-Island calls are matched individually and must each carry -Step and -Total. A single scan across the file passes as soon as one call has them, so removing -Step from just the error branch would otherwise stay green. - The $step/$local $total assignments are asserted to be positive. They are the "unset" sentinel everywhere else in this plugin (-1 means no sub-step), and setting them to -1 keeps every flag check green while the detail is dropped again at render time. A negative-injection pass caught that blind spot. - Negative injection: dropping -Step from the error branch, dropping -Total from the done branch, setting $step = -1, and inverting Build-DisplayMessage's Step<=0 early return each turn the suite red. --- .../hooks/scripts/post-tool-use.ps1 | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) 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 be005f80..2d3363bf 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 @@ -32,9 +32,18 @@ if ($summary -and $summary.StartsWith("$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" -Detail $detail + Push-Island -State error -Message "$tool failed" -Detail $detail -Step $step -Total $total } else { - Push-Island -State done -Message "$tool ok" -Detail $detail + Push-Island -State done -Message "$tool ok" -Detail $detail -Step $step -Total $total } exit 0 From 5fbda03d996964f1cbcfad19defc7f69c8372337 Mon Sep 17 00:00:00 2001 From: antianqi Date: Sun, 27 Sep 2026 15:32:23 +0800 Subject: [PATCH 13/38] ci(mcode-island): run test-substep-progress on windows-latest (round-19 #5) scripts/test-substep-progress.mjs existed but no workflow invoked it, so none of its assertions gated anything. It is also the only suite that runs the PowerShell under test rather than grepping the source for substrings, and it is where the secret canary for review item #1 lives -- so leaving it unwired meant a redaction regression could land without anything noticing. A new step 5 runs it after the existing four. PowerShell resolution: the suite prefers $env:PS_BIN, then an existing path if PS_BIN looks like one, then probes pwsh / powershell.exe / powershell on PATH. It never assumes a user-specific absolute path, and it fails loudly when no PowerShell is found rather than silently skipping. The workflow step logs which shell was resolved before invoking it. Two encoding issues had to be solved for the suite to produce trustworthy results on a runner whose console code page is not UTF-8: - the generated .ps1 needs a BOM, or PowerShell 5.1 reads it as ANSI and mangles any non-ASCII literal in the harness; - the child must set [Console]::OutputEncoding, or a middle dot comes back as two GBK bytes and two correct assertions fail as bogus diffs. Without both, the suite fails on correct code, which is worse than not running it: it would train everyone to ignore it. --- .github/workflows/mcode-island-windows.yml | 41 ++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/.github/workflows/mcode-island-windows.yml b/.github/workflows/mcode-island-windows.yml index 2445f47d..b1c2023b 100644 --- a/.github/workflows/mcode-island-windows.yml +++ b/.github/workflows/mcode-island-windows.yml @@ -411,3 +411,44 @@ 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 ($((& $c -NoProfile -Command '$PSVersionTable.PSVersion.ToString()')))" + } else { + Write-Host "No PowerShell found on PATH; the suite will skip its behavioral checks and fail" + } + + node scripts/test-substep-progress.mjs + if ($LASTEXITCODE -ne 0) { + throw "test-substep-progress.mjs failed with exit code $LASTEXITCODE" + } From ea96d753f7df1c23e35314a1c8c8d63bb2e057ff Mon Sep 17 00:00:00 2001 From: antianqi Date: Sun, 27 Sep 2026 15:51:47 +0800 Subject: [PATCH 14/38] ci(mcode-island): resolve the suite path from the repo root (round-19 #5 fix) The step I added in 5fbda03 ran `node scripts/test-substep-progress.mjs`. GitHub Actions executes every `run:` block from $GITHUB_WORKSPACE, not from the plugin directory, so node looked for /scripts/test-substep-progress.mjs which does not exist, and the run died with MODULE_NOT_FOUND after 32s. It passed locally only because my shell happened to already be sitting in plugins/antianqi/mcode-island. A test that only runs from one directory is not a test. The path is now resolved against the repo root and checked with Test-Path before node runs, so a future rename fails with a clear message naming the cwd instead of a node stack trace. Also fixes a bug in the same step: the version banner ran `& $c` after the loop, where $c is the last candidate rather than the shell that was actually selected. On a runner without pwsh the banner would have invoked the wrong binary, or nothing at all. --- .github/workflows/mcode-island-windows.yml | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/.github/workflows/mcode-island-windows.yml b/.github/workflows/mcode-island-windows.yml index b1c2023b..2046c281 100644 --- a/.github/workflows/mcode-island-windows.yml +++ b/.github/workflows/mcode-island-windows.yml @@ -443,12 +443,24 @@ jobs: if (Get-Command $c -ErrorAction SilentlyContinue) { $chosen = $c; break } } if ($chosen) { - Write-Host "PowerShell on PATH: $chosen ($((& $c -NoProfile -Command '$PSVersionTable.PSVersion.ToString()')))" + Write-Host "PowerShell on PATH: $chosen ($((& $chosen -NoProfile -Command '$PSVersionTable.PSVersion.ToString()')))" } else { - Write-Host "No PowerShell found on PATH; the suite will skip its behavioral checks and fail" + Write-Host "No PowerShell found on PATH; the suite will fail loudly rather than skip" } - node scripts/test-substep-progress.mjs + # 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" } From 12b70cba2dd0973c864e8d0af91a8f70dbf5c38f Mon Sep 17 00:00:00 2001 From: antianqi Date: Tue, 29 Sep 2026 15:17:22 +0800 Subject: [PATCH 15/38] fix(mcode-island): round-20 - close a false disclosure claim and two unbounded-growth leaks Audited the plugin against the 0.5.0-0.5.8 changelog and the live runtime state. Three real defects, each with a regression test that was verified to fail before the fix landed. 1. The 5h-usage disclosure named a host the code never called. Get-5hUsage.ps1 obfuscates the endpoint into a byte array (PS 5.1 parser-quirk defense). The bytes decode to api.minimaxi.com, which is correct and is what the running detector actually calls. But the trailing comment on that same line said api.minimax.com, and the README named api.minimax.io in three places -- including an absolute claim that the token is "never sent to a host other than api.minimax.io". That claim was false: the code never contacted that host at all. For a plugin whose pitch is four independent disclosure sections, an overstated isolation guarantee is worse than no guarantee: a reviewer checking host consistency would find the code and the docs disagreeing and would be right to distrust both. The smoke check now DECODES the byte array and compares the docs against that decoded truth. It never hard-codes the endpoint, so it cannot become a second copy of the disclosure it polices, and it fails the moment code and docs drift apart again. 2. island.log and widget.log had no size cap, ever. mcode-island.ps1 has documented "keep only the last 1KB" since the widget was written. Dbg was a bare Add-Content and never implemented it. Measured on this machine: widget.log reached 37.2 MB and island.log 18.0 MB over 38 days, with widget.log growing ~3.6 MB/day during active use. Extrapolated, that is over a GB a year in %APPDATA%, and nothing ever trimmed it. Both writers now cap at 1 MB and keep the last 300 lines. The size check is counter-gated (fires once per 256 KB written) so the 400 ms poll path does not stat the file on every call. 3. Tool input carrying credentials was persisted verbatim. Round-19 #1 redacted Computer Use keystrokes because they can be passwords. The Bash branch was never redacted, so `curl -H "Authorization: Bearer ..."` or `export API_KEY=sk-...` reached all three sinks -- status.json, the append-only log, and the always-on-top pill -- in the clear, and the log kept it indefinitely. There are two independent producers of that text: the hook path (_lib.ps1) and the detector, which re-derives messages from mcode's session log rather than from a hook event. A helper wired into only one is a silent half-fix -- the pill looks clean while the detector keeps writing raw text to disk. Both now route through a shared scripts/lib/Protect-Text.ps1, and smoke check 9 asserts BOTH consumers invoke it rather than merely defining it. Scope is deliberately credential-shaped runs, not whole commands: the command text on the pill is a documented feature, and blanking every command would trade a real capability for a marginal privacy gain. Also adds a regression guard for hook invocations under an install path containing spaces. mcode 0.5.4 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 joined string instead of an argv array. hooks.json already uses the array form, so this is a guard, not a fix. Validation ---------- - smoke: 115 pass / 0 fail (7 pre-existing "forward" event warnings). Baseline before this commit was 108 pass / 0 fail; the 9 new failures were the new checks, observed failing first. - Negative-first: each check was confirmed to FAIL before its fix landed, and check 9 was re-verified by deleting the detector's call site and watching it fail, then restoring. - Protect-SecretText: 16/16 cases, covering 10 credential shapes (bearer header, sk-, ghp-, bare JWT, JSON quoted key incl. "x-auth-token", single-quoted JSON, --password flag, key=value, the plugin's own set-token.ps1 invocation) and 6 near-miss inputs that must NOT be redacted (--mode production, "Select-String token", Windows paths, --secret-config, --author=, {"author":"Bob"}). - Log cap: wrote 3.4 MB of input through the new Log-Line body; final file 0.64 MB / 7064 lines. Cap held across repeated truncation. - PowerShell parser check clean on all five modified/new .ps1 files. - The 5h call was verified live against the real endpoint, not assumed: Get-5hUsage returns a real remainingPct with the configured token. Not included ------------ The context-meter and token-speed readouts proposed in the same audit are deliberately NOT in this commit. Structurally enumerating the session store (excluding the session this audit was run in) shows the assistant message carries model / usage.{input,output,cacheRead,cacheWrite, totalTokens,cost} and nothing else: there is no contextWindow and no tps field. usage.totalTokens is a usable context numerator, but the window size lives in mcode's provider config, and a token-rate derived from message timestamps would fold in thinking and tool time -- which 0.5.6 explicitly excludes from the TUI's own figure. Shipping either without that denominator would put a number on the pill that disagrees with the TUI. Left for a follow-up that can source the window size properly. --- plugins/antianqi/mcode-island/README.md | 6 +- .../io.minimax.mcode/hooks/scripts/_lib.ps1 | 10 + .../antianqi/mcode-island/mcode-island.ps1 | 31 ++- .../mcode-island/mcode-status-detect.ps1 | 38 ++++ .../mcode-island/scripts/lib/Get-5hUsage.ps1 | 2 +- .../mcode-island/scripts/lib/Protect-Text.ps1 | 103 +++++++++ .../antianqi/mcode-island/scripts/smoke.mjs | 213 ++++++++++++++++++ plugins/antianqi/mcode-island/skills/SKILL.md | 2 +- .../mcode-island/skills/mcode-island/SKILL.md | 2 +- 9 files changed, 398 insertions(+), 9 deletions(-) create mode 100644 plugins/antianqi/mcode-island/scripts/lib/Protect-Text.ps1 diff --git a/plugins/antianqi/mcode-island/README.md b/plugins/antianqi/mcode-island/README.md index fd3a5817..721f914c 100644 --- a/plugins/antianqi/mcode-island/README.md +++ b/plugins/antianqi/mcode-island/README.md @@ -254,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 | @@ -289,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 @@ -314,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 9db91abe..6980a2e0 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 @@ -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, @@ -135,6 +139,12 @@ function Format-ToolSummary { } } 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/mcode-island.ps1 b/plugins/antianqi/mcode-island/mcode-island.ps1 index 93955eab..3cfeafc3 100644 --- a/plugins/antianqi/mcode-island/mcode-island.ps1 +++ b/plugins/antianqi/mcode-island/mcode-island.ps1 @@ -4,11 +4,36 @@ $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)" diff --git a/plugins/antianqi/mcode-island/mcode-status-detect.ps1 b/plugins/antianqi/mcode-island/mcode-status-detect.ps1 index f07b3aed..b8e6e046 100644 --- a/plugins/antianqi/mcode-island/mcode-status-detect.ps1 +++ b/plugins/antianqi/mcode-island/mcode-status-detect.ps1 @@ -164,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 } @@ -266,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 } } @@ -514,6 +546,12 @@ function Infer-State($msg) { if ($args) { # ConvertTo-Json is a single .NET call; keep it (no pipeline leak). $argsJson = $args | ConvertTo-Json -Compress -Depth 2 -WarningAction SilentlyContinue + # Redact BEFORE truncating (round-20). This message reaches + # status.json, island.log and the pill, and `$args` is the raw + # tool argument object straight out of the session log -- so a + # Bash call carrying a bearer token or an API key was persisted + # verbatim even after the hook path was redacted. + $argsJson = Protect-SecretText $argsJson if ($argsJson.Length -gt 60) { $argsJson = $argsJson.Substring(0, 57) + $DOTS } return @{ state=$S_WORKING; message="$verb $argsJson"; family=(Get-ToolFamily $tool) } } 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 53af2bf3..5b15c512 100644 --- a/plugins/antianqi/mcode-island/scripts/smoke.mjs +++ b/plugins/antianqi/mcode-island/scripts/smoke.mjs @@ -928,6 +928,219 @@ 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)`); + } + } + } + finish(); }; 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 dc9bfde0..0d1a8c40 100644 --- a/plugins/antianqi/mcode-island/skills/mcode-island/SKILL.md +++ b/plugins/antianqi/mcode-island/skills/mcode-island/SKILL.md @@ -207,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. From df3c14da24fcb4c33642f023b26f8f99d931aed5 Mon Sep 17 00:00:00 2001 From: antianqi Date: Tue, 29 Sep 2026 15:21:11 +0800 Subject: [PATCH 16/38] fix(mcode-island): every non-ASCII .ps1 needs a UTF-8 BOM for PS 5.1 (round-20 #4) Found while landing round-20: syncing the committed tree into the install directory with the documented `Copy-Item -Recurse -Force` flow produced a detector that could not start at all. Root cause, verified against Windows PowerShell 5.1 rather than inferred: Windows PowerShell 5.1 decodes a BOM-less script using the system ANSI codepage. On a zh-CN host that is GBK, so a script containing non-ASCII comments is mis-decoded. The C# here-string in mcode-status-detect.ps1 then stops being a here-string, `using System;` is parsed as PowerShell, and the script dies before its first statement with 92 lines of "missing using directive". The reported line number (38) is offset from the real one (46) because the mis-decode also desynchronizes the tokenizer's line count. mcode-island.ps1 never showed this because it happens to carry a BOM. Nothing else in the plugin did. The trap is that .gitattributes pins `*.ps1 text eol=lf`, and its comment claimed "PowerShell 5.1 reads CRLF fine, so LF avoids both failure modes". That is backwards, and the comment is what let the bug survive. The policy is part of the cause, not a mitigation. Control experiment, to be clear that this is pre-existing and not a round-20 regression: the detector as of ea96d75 (before any of this round's work) fails under 5.1 with byte-identical output. It simply had never been synced from a clean checkout on this machine before. Why a BOM rather than flipping .gitattributes to CRLF: Both were tested against 5.1 and both fix the parse -- bare LF does not. CRLF would re-break the Linux-side validate.mjs CRLF handling that the same comment was written to avoid, so the BOM is the narrower fix: three bytes at the head of the file, line-ending policy untouched. Validation ---------- - Control: ea96d75's detector, run under 5.1, fails identically. Not a regression introduced here. - Isolated: the same file, byte-identical except for a prepended BOM, parses cleanly under 5.1 and runs to completion (the only remaining error was a TEMP-path artifact of the harness, since $PSScriptRoot had no scripts/lib beside it). - Also confirmed: CRLF without a BOM also parses, so bare LF is the trigger, not the encoding per se. - smoke check 11: written first, observed FAIL on exactly the 8 affected files, then PASS after the BOMs landed. 116 pass / 0 fail. - All 20 non-ASCII .ps1 tokenized with the 5.1 PSParser: 0 parse errors (was 8 files failing before). Files given a BOM (8): mcode-status-detect.ps1, set-token.ps1, _lib.ps1, post-tool-use.ps1, pre-compact.ps1, session-end.ps1, stop.ps1, test-windows-workflow-local.ps1. .g gitattributes now documents the real constraint, states that bare LF is part of the problem, and warns that the BOMs must not be "cleaned up" -- an editor that drops them breaks every sync. Note for reviewers: this changes the first byte range of eight files, so the diff reads as one changed line each. That is the BOM and nothing else. --- plugins/antianqi/mcode-island/.gitattributes | 26 ++++++-- .../io.minimax.mcode/hooks/scripts/_lib.ps1 | 2 +- .../hooks/scripts/post-tool-use.ps1 | 2 +- .../hooks/scripts/pre-compact.ps1 | 2 +- .../hooks/scripts/session-end.ps1 | 2 +- .../io.minimax.mcode/hooks/scripts/stop.ps1 | 2 +- .../mcode-island/mcode-status-detect.ps1 | 2 +- .../antianqi/mcode-island/scripts/smoke.mjs | 62 ++++++++++++++++++- .../scripts/test-windows-workflow-local.ps1 | 2 +- plugins/antianqi/mcode-island/set-token.ps1 | 2 +- 10 files changed, 91 insertions(+), 13 deletions(-) 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/io.minimax.mcode/hooks/scripts/_lib.ps1 b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/_lib.ps1 index 6980a2e0..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 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 2d3363bf..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,4 +1,4 @@ -# Hook: PostToolUse +# Hook: PostToolUse # Event: io.minimax.mcode / PostToolUse # State: done / error # Note: Fires after every tool call returns. Heuristic: if the 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-status-detect.ps1 b/plugins/antianqi/mcode-island/mcode-status-detect.ps1 index b8e6e046..c140bf90 100644 --- a/plugins/antianqi/mcode-island/mcode-status-detect.ps1 +++ b/plugins/antianqi/mcode-island/mcode-status-detect.ps1 @@ -1,4 +1,4 @@ -# mcode-status-detect.ps1 - 后台守护进程 (v0.2.1) +# mcode-status-detect.ps1 - 后台守护进程 (v0.2.1) # 监听 mcode session log,实时推断 agent 状态,写到 status.json # 让 widget 不依赖 agent 主动调 notify-island.ps1 也能跟着动 # diff --git a/plugins/antianqi/mcode-island/scripts/smoke.mjs b/plugins/antianqi/mcode-island/scripts/smoke.mjs index 5b15c512..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'; @@ -1141,6 +1141,66 @@ const main = async () => { } } + // 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-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 # 显示当前是否已配置 From 4876ef17f80cb8bcdf1cafbf438ce5c8facd4be0 Mon Sep 17 00:00:00 2001 From: antianqi Date: Tue, 29 Sep 2026 15:27:29 +0800 Subject: [PATCH 17/38] fix(mcode-island): a detector state write clears a stale sub-step (round-20 #5) Live symptom: the pill sat on "step 1/1" showing a curl command from a tool call that finished several turns earlier, and nothing ever cleared it. Cause: round-19 #2 made Write-Status merge the previous step/total/detail back in on every write, which is correct for the two metadata refreshes (60s 5h-usage, todo progress) because those restate the current state rather than announcing a new step. But the detector also writes on state inference -- 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 it inherited a counter that had no meaning there. Every subsequent write inherited it again, which is why it never cleared. Write-Status now takes -KeepSubStep: - state inference (default) resets step/total/detail to -1/-1/"" - the 5h-usage and todo refreshes pass -KeepSubStep and preserve The two paths are distinguished explicitly by the caller, not inferred from the message text. Distinguishing is necessary because the state-inference call also has to be able to win the settle race against an agent push (round-19), so "who wrote last" is not a usable signal. Tests ----- New section 5 in test-substep-progress.mjs, negative-first: observed failing on 3 assertions before the fix, all passing after. - Write-Status exposes -KeepSubStep - state-inference write clears a stale sub-step (-1|-1|) - metadata-refresh write still preserves it - the inference call site does NOT pass -KeepSubStep - exactly 2 call sites DO (5h-usage, todo) The call-site assertions are the ones that matter: a switch nobody passes is the dead-code shape, and a test that only exercises the function body would not notice. Two harness repairs, both regressions from round-20 that this work surfaced: - Format-ToolSummary now calls Protect-SecretText, which lives in a separate lib. The harness extracted the function body and ran it standalone, so it died on an unresolvable command rather than on an assertion. The harness now dot-sources the lib, as _lib.ps1 does. This is a genuine break of an existing test that round-20 shipped; smoke did not catch it because smoke is static and this suite is not. - Section 3 anchored its extraction regex on `$prev` appearing before `$detailField`. Wrapping the block in `if ($KeepSubStep)` inverted that order and the regex silently stopped matching, which would have been reported as "Write-Status preserves sub-step fields: could not find the block" -- a confusing failure for a function that works. It now anchors on the initialisers and the payload construction. Validation: sub-step suite 32 pass / 0 fail, smoke 116 pass / 0 fail. Detector re-verified under Windows PowerShell 5.1 (exit 0) after the restart, since that is the interpreter this runs under in production. --- .../mcode-island/mcode-status-detect.ps1 | 38 ++++-- .../scripts/test-substep-progress.mjs | 122 ++++++++++++++++-- 2 files changed, 139 insertions(+), 21 deletions(-) diff --git a/plugins/antianqi/mcode-island/mcode-status-detect.ps1 b/plugins/antianqi/mcode-island/mcode-status-detect.ps1 index c140bf90..da749c21 100644 --- a/plugins/antianqi/mcode-island/mcode-status-detect.ps1 +++ b/plugins/antianqi/mcode-island/mcode-status-detect.ps1 @@ -580,7 +580,7 @@ function Infer-State($msg) { return $null } -function Write-Status($state, $message, $family) { +function Write-Status($state, $message, $family, [switch]$KeepSubStep) { $tmp = "$statusFile.tmp" # usage5h:0..100 表示"剩余"百分比(不是已用!);null = 未知/未拉到 # usage5hResetMs:距下次刷新的毫秒数;null = 未知 @@ -592,17 +592,31 @@ function Write-Status($state, $message, $family) { $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 每次刷 5h 用量或 todo 进度都会重写整个 payload,如果这里 - # 不把现有值读回来合并,正常使用中的 sub-step 进度会被静默清零 - # (round-19 review hetaoBackend #2)。读-改-写,不是覆盖。 - $prev = Read-StatusObj + # 推的。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 = '' - 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 ($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 } + } } $payload = [PSCustomObject]@{ state = $state @@ -808,7 +822,8 @@ try { $sForU = if ($curForUsage) { [string]$curForUsage.state } else { $S_IDLE } $mForU = if ($curForUsage) { [string]$curForUsage.message } else { '' } $fForU = if ($curForUsage -and $curForUsage.PSObject.Properties['family']) { $curForUsage.family } else { $null } - Write-Status $sForU $mForU $fForU + # 元数据刷新:原样写回当前状态,必须保留 sub-step(-KeepSubStep) + Write-Status $sForU $mForU $fForU -KeepSubStep Log-Line ("5h usage refreshed: remaining=" + $curPct + "% resetMs=" + $curMs) } @@ -822,7 +837,8 @@ try { $sForT = if ($curForTodo) { [string]$curForTodo.state } else { $S_IDLE } $mForT = if ($curForTodo) { [string]$curForTodo.message } else { '' } $fForT = if ($curForTodo -and $curForTodo.PSObject.Properties['family']) { $curForTodo.family } else { $null } - Write-Status $sForT $mForT $fForT + # 元数据刷新:原样写回当前状态,必须保留 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/scripts/test-substep-progress.mjs b/plugins/antianqi/mcode-island/scripts/test-substep-progress.mjs index 5a908ee2..3e796815 100644 --- a/plugins/antianqi/mcode-island/scripts/test-substep-progress.mjs +++ b/plugins/antianqi/mcode-island/scripts/test-substep-progress.mjs @@ -139,6 +139,13 @@ if (!PS_BIN) { 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'); @@ -151,6 +158,7 @@ if (!PS_BIN) { const harness = ` $ErrorActionPreference = 'Stop' +. '${protectLibPath.replace(/'/g, "''")}' ${formatToolSummary} $evt = [PSCustomObject]@{ tool_name = 'mcode-computer-use' @@ -179,6 +187,7 @@ Format-ToolSummary $evt // 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' @@ -300,29 +309,33 @@ if (PS_BIN) { const writeStatusFn = extractFn(detect, 'Write-Status'); - // Extract just the sub-step preservation lines as a standalone repro of - // the logic, so the test does not need the detector's whole environment. - const preserveMatch = writeStatusFn.match( - /\$prev = Read-StatusObj[\s\S]*?\$detailField = ''[\s\S]*?if \(\$prev\)\s*\{[\s\S]*?\n\s*\}/); + // 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 read-modify-write block in Write-Status'); + '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 } } -$prev = Read-StatusObj -$stepField = -1 -$totalField = -1 -$detailField = '' -${preserveMatch[0].split('\n').slice(1).join('\n')} +${preserveMatch[0].replace(/\$payload\s*=$/, '')} "$stepField|$totalField|$detailField" `; const statusFile = join(TMP, 'status.json'); @@ -376,6 +389,95 @@ ${preserveMatch[0].split('\n').slice(1).join('\n')} } } +// --------------------------------------------------------------------------- +// 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) +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" +`; + + 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), '-1|-1|'); + + writeFileSync(statusFile, JSON.stringify(stale), 'utf8'); + eq('metadata-refresh write (-KeepSubStep) still preserves it', + run(true), '1|1|curl -H "Authorization: Bearer "'); + } + + // 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}`); +} + // --------------------------------------------------------------------------- // summary // --------------------------------------------------------------------------- From 5e095180e9afedb00efc8046d307a720704241ba Mon Sep 17 00:00:00 2001 From: antianqi Date: Tue, 29 Sep 2026 16:04:36 +0800 Subject: [PATCH 18/38] fix(mcode-island): the pill never renders raw tool JSON (round-20 #6) The running-state message was built as "$verb " + (ConvertTo-Json $args -Compress) truncated to 60 chars so the always-on-top pill showed things like Running {"command":"$ErrorActionPreference=\u0027Continue\u0027\n... Reading task {"task_id":"bg_0f858500-...","wai... Escaped quotes, a JSON key, and a truncation that lands mid-token. It was the noisiest thing on screen and the least informative. The `done` and `fail` branches were always fine -- they render the verb alone. Only the `running` branch serialised arguments. Format-ToolArgs now returns the single most informative field per tool: command for shell, file_path for read/write/edit, pattern for grep/glob, query for web_search, url for web_fetch, name for skill, task_id for the task-output family. Unknown tools get a generic sweep of the common field names before giving up. `skill` is not in $TOOL_ACTIONS or $TOOL_FAMILIES, so it always took the JSON path -- one of the most frequent tools on screen was the least readable. That is the case that made this worth fixing rather than tuning. There is no fallback to JSON. If Format-ToolArgs returns nothing the pill wears the bare verb: "Using" reads fine, "Using {\"name\":...}" does not. An unknown tool should degrade to less information, never to a dump. Also fixes the display of multi-line commands. A PowerShell command is frequently several lines; the old code collapsed and truncated the whole thing, so the visible text was usually the middle of a script fragment. It now takes the first non-empty line. Redaction is unchanged and still applied on this path -- the summary runs through Protect-SecretText exactly as the JSON did, so a credential in a command still cannot reach the pill or island.log by this route. Tests: 16 new assertions in test-substep-progress.mjs, negative-first. The blanket rule is asserted per case: no result may contain a JSON key, an escaped quote, or a \uXXXX sequence. Cases cover bash, multi-line bash, read/write/edit, grep, glob, web_search, web_fetch, skill, task_output, an unknown tool with an unrecognised argument shape, and a bearer token inside a command. Validation: sub-step suite 47 pass / 0 fail, smoke 116 pass / 0 fail, all 30 .ps1 parse, and the detector still initialises cleanly under Windows PowerShell 5.1 (exit 0) -- which is the interpreter it runs under in production, and the one that breaks on a malformed file. One trap worth recording, because it produced a very misleading failure: the second parameter is deliberately NOT named `$args`. That is a reserved automatic variable in PowerShell, and binding a parameter to it yields the unbound-argument Object[] rather than the object passed in. The symptom was an empty summary for every single tool, which reads as "no data" rather than "bad binding". Verified directly: function T($n, $args) { $args.command } T bash ([PSCustomObject]@{ command = 'npm test' }) # -> '' --- .../mcode-island/mcode-status-detect.ps1 | 94 ++++++++++++-- .../scripts/test-substep-progress.mjs | 119 ++++++++++++++++++ 2 files changed, 202 insertions(+), 11 deletions(-) diff --git a/plugins/antianqi/mcode-island/mcode-status-detect.ps1 b/plugins/antianqi/mcode-island/mcode-status-detect.ps1 index da749c21..5d9bfb88 100644 --- a/plugins/antianqi/mcode-island/mcode-status-detect.ps1 +++ b/plugins/antianqi/mcode-island/mcode-status-detect.ps1 @@ -446,6 +446,83 @@ function Get-ToolFamily($name) { 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. A PowerShell or bash command is frequently + # multi-line, so take the first non-empty line rather than letting the + # body spill in and get truncated mid-token. + $first = '' + foreach ($ln in ($raw -split "`r?`n")) { + if ($ln.Trim() -ne '') { $first = $ln.Trim(); break } + } + 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 @@ -543,17 +620,12 @@ function Infer-State($msg) { } $verb = Get-ToolVerb $tool 'running' if (-not $verb) { $verb = Get-ToolVerbFallback $tool 'running' } - if ($args) { - # ConvertTo-Json is a single .NET call; keep it (no pipeline leak). - $argsJson = $args | ConvertTo-Json -Compress -Depth 2 -WarningAction SilentlyContinue - # Redact BEFORE truncating (round-20). This message reaches - # status.json, island.log and the pill, and `$args` is the raw - # tool argument object straight out of the session log -- so a - # Bash call carrying a bearer token or an API key was persisted - # verbatim even after the hook path was redacted. - $argsJson = Protect-SecretText $argsJson - if ($argsJson.Length -gt 60) { $argsJson = $argsJson.Substring(0, 57) + $DOTS } - return @{ state=$S_WORKING; message="$verb $argsJson"; family=(Get-ToolFamily $tool) } + # 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) } } diff --git a/plugins/antianqi/mcode-island/scripts/test-substep-progress.mjs b/plugins/antianqi/mcode-island/scripts/test-substep-progress.mjs index 3e796815..80e1799a 100644 --- a/plugins/antianqi/mcode-island/scripts/test-substep-progress.mjs +++ b/plugins/antianqi/mcode-island/scripts/test-substep-progress.mjs @@ -478,6 +478,125 @@ ${block[0].replace(/\$payload\s*=$/, '')} `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' }, + { n: 'bashMulti', t: 'bash', a: { command: "$x = 'Stop'\nGet-ChildItem" }, want: '$x' }, + { 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. + if ('bashMulti' in got && /Get-ChildItem/.test(got.bashMulti)) { + bad('summary bashMulti', 'a multi-line command leaked its second line onto the pill'); + } else if ('bashMulti' in got) { + ok('summary bashMulti keeps only the first line'); + } + } +} + // --------------------------------------------------------------------------- // summary // --------------------------------------------------------------------------- From 13b90ab0da749f391c2c4e3b4efa130cad510d45 Mon Sep 17 00:00:00 2001 From: antianqi Date: Tue, 29 Sep 2026 16:08:46 +0800 Subject: [PATCH 19/38] fix(mcode-island): a metadata refresh no longer steals state ownership (round-20 #7) Reported as: the pill stops on "Command failed" at the end of a turn. `source` is not a label -- it is the arbitration token. The detector's settle logic reads `source == detector` as "this state is mine to rewrite", and uses that to decide whether it may overwrite an agent push. But Write-Status stamped `source = detector` unconditionally, including on the two metadata refreshes (60s 5h-usage, todo progress) that exist only to refresh numbers on a state somebody else owns. The sequence, from island.log on the dev machine: 16:06:24 [detect] error :: Command failed 16:06:25 error :: Command failed <- PostToolUse hook, source=agent 16:06:40 [detect] 5h usage refreshed <- stamps detector over the agent's state 16:06:41 error :: Command failed 16:07:03 [detect] idle :: waiting <- 60s no-event fallback, finally At 16:06:40 the refresh handed write authority back to the detector while leaving `state = error`. From that point the detector owned the state and re-derived `error` from the same stale failed toolResult on every poll -- there is nothing newer in the session log to supersede it. When the turn ended and the Stop hook pushed `done`, the detector was entitled to overwrite it, and the pill went back to red. Nothing cleared it until the 60-second no-event fallback happened to fire. So the error was accurate when it appeared and wrong the moment the turn moved on. The user-visible symptom is a pill that is stuck on a failure that is no longer the current state. A metadata refresh now preserves the previous owner. The claim-ownership stamp is reserved for state-inference writes, which is the only path that actually asserts a new state. This is the same distinction #5 drew for step/total/detail; `source` belongs to the observable state, so it follows the same rule. Tests: 2 new assertions, negative-first, both observed failing before the fix. They assert the ownership token in both directions -- a metadata-refresh write keeps `agent`, a state-inference write claims `detector` -- because a test that only checks one side would pass just as happily if the field were always blank. Validation: sub-step suite 49 pass / 0 fail (was 47), smoke 116 pass / 0 fail, all 30 .ps1 parse, detector initialises cleanly under Windows PowerShell 5.1 (exit 0). --- .../mcode-island/mcode-status-detect.ps1 | 17 +++++++++++- .../scripts/test-substep-progress.mjs | 26 ++++++++++++++++--- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/plugins/antianqi/mcode-island/mcode-status-detect.ps1 b/plugins/antianqi/mcode-island/mcode-status-detect.ps1 index 5d9bfb88..e8cb7e98 100644 --- a/plugins/antianqi/mcode-island/mcode-status-detect.ps1 +++ b/plugins/antianqi/mcode-island/mcode-status-detect.ps1 @@ -682,12 +682,27 @@ function Write-Status($state, $message, $family, [switch]$KeepSubStep) { $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]@{ @@ -703,7 +718,7 @@ function Write-Status($state, $message, $family, [switch]$KeepSubStep) { 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 diff --git a/plugins/antianqi/mcode-island/scripts/test-substep-progress.mjs b/plugins/antianqi/mcode-island/scripts/test-substep-progress.mjs index 80e1799a..f50fe24d 100644 --- a/plugins/antianqi/mcode-island/scripts/test-substep-progress.mjs +++ b/plugins/antianqi/mcode-island/scripts/test-substep-progress.mjs @@ -433,14 +433,16 @@ ${preserveMatch[0].replace(/\$payload\s*=$/, '')} $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" +"$stepField|$totalField|$detailField|$sourceField" `; - const statusFile = join(TMP, 'status.json'); const run = (keep) => runPs( `$env:ISLAND_TEST_STATUS = '${statusFile.replace(/'/g, "''")}'\n` + @@ -454,11 +456,27 @@ ${block[0].replace(/\$payload\s*=$/, '')} }; writeFileSync(statusFile, JSON.stringify(stale), 'utf8'); - eq('state-inference write clears the stale sub-step', run(false), '-1|-1|'); + 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), '1|1|curl -H "Authorization: Bearer "'); + 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 From a6a17f3eba855e1fe563db0a2948ab3cbfdf65d6 Mon Sep 17 00:00:00 2001 From: antianqi Date: Tue, 29 Sep 2026 16:21:26 +0800 Subject: [PATCH 20/38] fix(mcode-island): skip the assignment preamble in shell summaries (round-20 #8) Round-20 #6 stopped the pill from rendering raw JSON, but the result was still frequently uninformative: Running $ErrorActionPreference='Continue' That is the opening line of most agent commands, and it says nothing about what is being worked on. On a detached status pill it is close to indistinguishable from the pill being stuck -- which is not a good look for a widget whose entire job is to answer "what is it doing right now". Format-ToolArgs now prefers the first line that actually EXECUTES something, skipping variable-assignment and comment lines: $ErrorActionPreference='Continue' <- skipped $env:TEMP="..." <- skipped npm test <- shown The fallback matters: if every line is boilerplate, the first line is still shown rather than leaving the pill blank. A summary that is uninformative beats no summary at all, because the bare verb alone cannot be distinguished from the detector having lost track. Comment lines are skipped on the same rationale. Tests: 3 cases, negative-first, all observed failing before the fix -- the preamble was shown, and the comment line was shown. A fourth case (an all-assignment command) was written at the same time and passed before the fix as well, which is the point: it pins the fallback so a future "skip more lines" change cannot silently blank the pill. Validation: sub-step suite 51 pass / 0 fail (was 49), smoke 116 pass / 0 fail, all 30 .ps1 parse, detector initialises cleanly under Windows PowerShell 5.1 (exit 0). --- .../mcode-island/mcode-status-detect.ps1 | 23 +++++++++++++++---- .../scripts/test-substep-progress.mjs | 19 +++++++++++---- 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/plugins/antianqi/mcode-island/mcode-status-detect.ps1 b/plugins/antianqi/mcode-island/mcode-status-detect.ps1 index e8cb7e98..e550c060 100644 --- a/plugins/antianqi/mcode-island/mcode-status-detect.ps1 +++ b/plugins/antianqi/mcode-island/mcode-status-detect.ps1 @@ -506,13 +506,28 @@ function Format-ToolArgs($name, $toolArgs) { } if ([string]::IsNullOrWhiteSpace($raw)) { return '' } - # The pill is a single line. A PowerShell or bash command is frequently - # multi-line, so take the first non-empty line rather than letting the - # body spill in and get truncated mid-token. + # 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")) { - if ($ln.Trim() -ne '') { $first = $ln.Trim(); break } + $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 diff --git a/plugins/antianqi/mcode-island/scripts/test-substep-progress.mjs b/plugins/antianqi/mcode-island/scripts/test-substep-progress.mjs index f50fe24d..dc8fd475 100644 --- a/plugins/antianqi/mcode-island/scripts/test-substep-progress.mjs +++ b/plugins/antianqi/mcode-island/scripts/test-substep-progress.mjs @@ -547,7 +547,15 @@ foreach ($c in $cases) { const cases = [ { n: 'bash', t: 'bash', a: { command: 'npm test' }, want: 'npm test' }, - { n: 'bashMulti', t: 'bash', a: { command: "$x = 'Stop'\nGet-ChildItem" }, want: '$x' }, + // 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' }, @@ -606,11 +614,12 @@ foreach ($c in $cases) { 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. - if ('bashMulti' in got && /Get-ChildItem/.test(got.bashMulti)) { - bad('summary bashMulti', 'a multi-line command leaked its second line onto the pill'); + // 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 keeps only the first line'); + ok('summary bashMulti skips the assignment preamble'); } } } From 614d9a1c0af719b066f5a471fa0af2e47b14e403 Mon Sep 17 00:00:00 2001 From: antianqi Date: Thu, 22 Oct 2026 09:01:36 +0800 Subject: [PATCH 21/38] fix(mcode-island): the detector survives a terminating error in the main loop The main loop had no catch. Any terminating error escaped the loop, fired the finally block, logged 'detector stopped' and exited the process. The widget kept running, so the pill just froze on the last status.json with no visible cause -- status.json silently stopped changing while widget.log kept logging TOPMOST re-raises. This is the same failure family the round-21 review flagged for the status.json write path (a shared staging file let one process rename another's half-written file into place, and the resulting Move-Item failure killed the daemon). The lock in the next commit removes that specific trigger; this commit makes the daemon survive the ones we cannot enumerate. The catch logs the exception type as well as the message, so 'loop error' alone does not have to be disambiguated between a JSON parse, a WMI failure and an IO lock. The message is truncated to 300 characters so a runaway error cannot flood island.log every tick. Validation - the guard is a try/catch around the existing loop body; the loop's own logic is untouched - the detector still initialises cleanly under Windows PowerShell 5.1 (exit 0), the interpreter it runs under in production - all .ps1 files in the plugin still parse without syntax errors Design compliance no credentials / no network / no telemetry / no third-party services the change only adds an error handler around existing local work --- .../mcode-island/mcode-status-detect.ps1 | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/plugins/antianqi/mcode-island/mcode-status-detect.ps1 b/plugins/antianqi/mcode-island/mcode-status-detect.ps1 index e550c060..e8c0d3c9 100644 --- a/plugins/antianqi/mcode-island/mcode-status-detect.ps1 +++ b/plugins/antianqi/mcode-island/mcode-status-detect.ps1 @@ -825,6 +825,16 @@ if (-not $Once) { try { while ($true) { + # --- per-tick guard ------------------------------------------------------- + # This loop had no catch: any terminating error (malformed status.json, a + # WMI/CIM hiccup, a locked file, a bad cast in the todo parser) escaped the + # loop, fired the finally block, logged "detector stopped" and exited the + # process. The widget kept running, so the pill just froze on the last + # status.json with no visible cause -- status.json silently stopped + # changing while widget.log kept logging TOPMOST re-raises. Log the real + # message and retry on the next tick instead of dying. + # Indentation is intentionally flat here so the diff stays reviewable. + try { $now = Get-Date $inferred = $null $latestFile = $null @@ -951,6 +961,16 @@ try { if ($Once) { break } Start-Sleep -Milliseconds 1000 + } catch { + # Log the exception type too: "loop error" alone would not tell us whether + # this is a JSON parse, a WMI failure or an IO lock. Truncate so a runaway + # error cannot flood island.log every tick. + $msg = $_.Exception.GetType().Name + ': ' + $_.Exception.Message + if ($msg.Length -gt 300) { $msg = $msg.Substring(0, 300) + '...' } + Log-Line ('loop error (retrying): ' + $msg) + if ($Once) { break } + Start-Sleep -Milliseconds 1000 + } } } finally { if (-not $Once -and (Test-Path $pidFile)) { From 4ae074fe32f8e85280b2424cf7b6f3a840831cea Mon Sep 17 00:00:00 2001 From: antianqi Date: Thu, 22 Oct 2026 09:14:17 +0800 Subject: [PATCH 22/38] fix(mcode-island): status.json writes are atomic across processes Round-21 review (hetaoBackend, 2026-10-09) blocking issue #1: the detector's metadata refresh was not an atomic read-modify-write. The loop read old state/message (mcode-status-detect.ps1:923), then Write-Status -KeepSubStep performed a SECOND independent read for step/detail/source (:714-721) before overwriting the file (:723-739). An agent write between the two reads produced a mixed-generation payload, and a write after the second read was lost. There was a second writer behind that, and it made the outcome worse than a stale payload. notify-island.ps1 staged status.json through "$statusFile.tmp" and the detector staged through the same path. Two processes, one temp file: one could rename the other's half-written file into place, and the loser then failed its Move-Item. The detector runs under $ErrorActionPreference = 'Stop' with no catch around the write, so that failure killed the daemon outright -- the pill froze on the last status.json with no visible cause. (The preceding commit adds the per-tick guard that stops the daemon dying on ANY terminating error; this commit removes this particular trigger.) ## What changed scripts/lib/Status-Store.ps1 (new) The one place the status.json protocol lives, shared by both writers: Enter-StatusLock / Exit-StatusLock An exclusive cross-process lock on a sibling lock file, opened with FileShare.None. That is a real OS file lock, not a flag file whose presence we poll -- a flag file has the same check-then-act hole the payload has. If a holder dies, the OS releases the handle. Read-StatusSnapshot One read of the whole status object. Callers read once and reuse that snapshot. Write-StatusSnapshot Atomic replace (staging file + rename) that stamps the monotonic `version` counter and `ts`. mcode-status-detect.ps1 Write-Status now takes the lock, reads the snapshot ONCE, and writes. The body moved to Write-StatusUnderLock so the arbitration path can read the snapshot and act on it without taking the lock twice (the lock is not re-entrant: a nested take would deadlock against itself). The arbitration read and the write it authorises are now one critical section. Previously the read sat outside the lock, so a hook push could be judged against a stale snapshot and then overwritten by the write that stale snapshot had authorised. -KeepSubStep became -MetadataRefresh. The old switch only said "preserve the sub-step"; the call site still had to read the file itself to restate the state, which is where the second read came from. One switch now names the whole semantics: a metadata refresh restates the current state, preserves the sub-step fields and preserves the state owner. A state-inference write resets them and claims ownership. The two metadata call sites no longer touch the file at all. Read-StatusObj is gone. It read status.json without the lock, and it was the read that made a lost update possible. notify-island.ps1 The agent-side writer now uses the same lock, the same snapshot and the same staging file. caller.json moved inside the same critical section, so its staging path is no longer shared between two hook processes that can run at once. ## Two defects found while doing this 1. The replace could lose a sharing violation. MoveFileEx with REPLACE_EXISTING fails when the target is open by a reader that did not share delete, and the widget polls status.json every 400 ms with Get-Content, which opens with FileShare.Read. A write landing in that window threw and the update was lost -- the staging file survived, the target kept the old payload. Measured on this host, not inferred. The replace now retries briefly; the reader holds the file for microseconds, so one short retry clears it. 2. The write path returned the version, and the detector calls it from inside its main loop -- so the daemon's stdout got a bare number on every write. The store's write function returns nothing now, and a check pins that. ## Test evidence (negative-injection verified) The behavioral suite now runs the REAL Write-Status against a REAL status.json in an isolated %APPDATA%. The old harness stubbed the reader and pasted a fragment of the function body into a synthetic script -- which could not tell a working implementation from one that read the file twice, because the second read was exactly what the stub hid. That is the bug the review named, and the old test could not see it. test-substep-progress.mjs: 84 pass, 0 fail (was 51) smoke.mjs: 125 pass, 7 warn, 0 fail (was 116) New: two real PowerShell processes interleaving on one status.json. Writer A is the detector's read-modify-write, writer B is the agent's full push. A signals that it has read, then waits for B to signal that it is about to write; B's signal is published BEFORE B takes the lock, so the ordering is deterministic either way. with the lock A holds it, B blocks, A writes, B writes last -> the file carries B's values without the lock B writes immediately, A writes 400 ms later from its stale read -> the file carries A's stale values and B's write is gone The version counter is a second, independent witness: both writers derive it from the snapshot they read, so two writers that read the same generation mint the same version. 9 negative-injection cases, each a minimal change that breaks one clause of the contract. All 9 turn the suite red; the baseline restores to 80/0 after each: N1 lock removed from the write path -> 2 fail N2 the write path reads the file twice -> 2 fail N3 metadata refresh stops preserving sub-step -> 4 fail N4 state-inference stops clearing the sub-step -> 4 fail N5 the version counter stops advancing -> 3 fail N6 notify-island stages through a shared path -> 1 fail N7 the inference site passes -MetadataRefresh -> 1 fail N8 notify-island stops taking the lock -> 1 fail N9 metadata refresh stops preserving the owner -> 1 fail N6 and N8 did not bite on the first pass, and the reason was worth fixing rather than papering over: N6's check looked for the literal "$statusFile.tmp". A rewrite that spells the same shared path differently walks straight past it, so the suite stayed green while the bug was back. The check now asserts the POSITIVE contract -- status.json may only be written through the shared store -- instead of one spelling of the bug. N8's patch did not apply, because the indentation in the patch text did not match the file. A setup miss is reported as a setup miss, not as a pass: counting it as green would have been a false negative in the harness itself. The two layers are bound together explicitly. Section 7 proves the PROTOCOL -- two real processes, one file, no lost write -- using hand-written writers, because the deterministic choreography needs the read to be separable from the write, and the real code deliberately does not separate them. The static checks are what say that the real detector code goes through that protocol: the arbitration block takes the lock and writes through the locked body, and no other call site writes at all. Also verified: the detector still initialises and writes under Windows PowerShell 5.1 (exit 0, the interpreter it runs under in production), and the local Windows runner passes all 4 steps, including the real PreToolUse hook path through the new locked write. ## Design compliance no credentials : none added; the lock is a local file handle no network : no network calls added no telemetry : none added; island.log unchanged no third-party svcs : no new dependencies; pure PowerShell cross-platform : no host paths introduced; the existing hardcoded-path scan still passes atomic write : staging + rename, now under an exclusive lock schema : additive. `version` is a new optional field; the widget reads every field through PSObject.Properties, so an older widget ignores it and a newer one tolerates its absence. The sentinel convention is unchanged: absent means -1 / -1 / "". ASCII-only : the new lib is ASCII-only, so it needs no BOM and cannot trip the PS 5.1 codepage trap --- .../mcode-island/mcode-status-detect.ps1 | 208 ++++-- .../antianqi/mcode-island/notify-island.ps1 | 38 +- .../mcode-island/scripts/lib/Status-Store.ps1 | 174 +++++ .../antianqi/mcode-island/scripts/smoke.mjs | 105 ++- .../scripts/test-substep-progress.mjs | 613 ++++++++++++++---- 5 files changed, 930 insertions(+), 208 deletions(-) create mode 100644 plugins/antianqi/mcode-island/scripts/lib/Status-Store.ps1 diff --git a/plugins/antianqi/mcode-island/mcode-status-detect.ps1 b/plugins/antianqi/mcode-island/mcode-status-detect.ps1 index e8c0d3c9..e5149cb8 100644 --- a/plugins/antianqi/mcode-island/mcode-status-detect.ps1 +++ b/plugins/antianqi/mcode-island/mcode-status-detect.ps1 @@ -149,7 +149,8 @@ $V2_SESSIONS_REL = _s (0x2E,0x6D,0x69,0x6E,0x69,0x6D,0x61,0x78,0x5C,0x76,0x32,0x $configDir = Join-Path $env:APPDATA 'mcode-island' if (!(Test-Path $configDir)) { New-Item -ItemType Directory -Path $configDir -Force | Out-Null } -$statusFile = Join-Path $configDir 'status.json' +# status.json 的路径由 store 库统一解析(Get-StatusFilePath),这里不再 +# 维护第二份,避免两个来源对不上。 $logFile = Join-Path $configDir 'island.log' $pidFile = Join-Path $configDir 'detect.pid' $cfgFile = Join-Path $configDir 'config.json' @@ -169,6 +170,15 @@ $cfgFile = Join-Path $configDir 'config.json' # 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" +# Cross-process status.json protocol (round-21 review #1). +# +# The detector and the agent hooks are two independent processes that +# both write status.json. They had no mutual exclusion: the detector +# read the file, a hook wrote it, and the detector then overwrote the +# hook's write with a payload built from the older read. This lib holds +# the lock, the single snapshot read and the atomic replace, so both +# sides follow one protocol. +. "$PSScriptRoot/scripts/lib/Status-Store.ps1" $PLAN_API_TTL = [TimeSpan]::FromSeconds(60) $script:plan5hToken = $null if ($env:MINIMAX_OAUTH_TOKEN) { $script:plan5hToken = $env:MINIMAX_OAUTH_TOKEN } @@ -667,8 +677,43 @@ function Infer-State($msg) { return $null } -function Write-Status($state, $message, $family, [switch]$KeepSubStep) { - $tmp = "$statusFile.tmp" +# Writes status.json. +# +# Two write semantics, and they must not be confused (round-20 #5, #7): +# +# - state inference (default): the detector inferred a new state from +# the session log and is asserting it. The sub-step counters are +# reset: they belong to whatever the hook pushed last and have no +# relationship to this message. `source` becomes `detector`, which +# is the arbitration token that lets the detector rewrite this +# state later. +# +# - metadata refresh (-MetadataRefresh): the 60s 5h-usage refresh and +# the todo-progress refresh. They restate the current state and +# refresh numbers; they do not claim the state. So state, message, +# family, the sub-step counters and `source` all come from the +# snapshot taken in this same call. +# +# Both run under one exclusive lock and take exactly ONE snapshot +# (round-21 review hetaoBackend #1). The previous version read the +# file twice -- once here for the sub-step fields and once at the call +# site for the state -- and then wrote the result back over the whole +# file, so a hook write landing between the two reads was either mixed +# into a stale payload or lost outright. +function Write-Status($state, $message, $family, [switch]$MetadataRefresh) { + $lock = Enter-StatusLock + try { + Write-StatusUnderLock $state $message $family -MetadataRefresh:$MetadataRefresh + } finally { + Exit-StatusLock $lock + } +} + +# The body of Write-Status, for callers that already hold the lock. +# Splitting it out is what lets the arbitration path read the snapshot +# and act on it without taking the lock twice: the lock is not +# re-entrant, because a nested take would deadlock against itself. +function Write-StatusUnderLock($state, $message, $family, [switch]$MetadataRefresh) { # usage5h:0..100 表示"剩余"百分比(不是已用!);null = 未知/未拉到 # usage5hResetMs:距下次刷新的毫秒数;null = 未知 # todoProgress:0..100 完成百分比(cancelled 不计);null = 无 todo 列表 @@ -677,13 +722,31 @@ function Write-Status($state, $message, $family, [switch]$KeepSubStep) { $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 } + + # The one snapshot. Every field below is derived from it, so the + # payload is always a single coherent generation. + $prev = Read-StatusSnapshot + + $curState = $state + $curMsg = $message + $famField = if ($family) { [string]$family } else { $null } + + if ($MetadataRefresh) { + $curState = if ($prev) { [string]$prev.state } else { $S_IDLE } + $curMsg = if ($prev) { [string]$prev.message } else { '' } + # A null family must stay null, not become an empty string: the + # widget asks "is there a family to tint with", and "[string]$null" + # would answer with a string that means the same thing to it but + # changes the on-disk shape of the payload. + $famField = if ($prev -and $prev.PSObject.Properties['family'] -and $null -ne $prev.family) { [string]$prev.family } else { $null } + } + # step / total / detail 是 agent(hook)推的 sub-step 状态,不是 detector # 推的。detector 重写整个 payload 时,这里怎么处理取决于"为什么写"。 # # 两种写入语义完全不同(round-20 #5): # - # - 元数据刷新(-KeepSubStep):每 60s 刷 5h 用量、每次刷 todo 进度。 + # - 元数据刷新(-MetadataRefresh):每 60s 刷 5h 用量、每次刷 todo 进度。 # 这两次的 state/message 是从当前 status.json 读回来原样再写回去的, # 不代表"agent 进入了新的一步",所以必须把已有的 sub-step 合并回来, # 否则正常使用中的进度会被静默清零(round-19 #2)。 @@ -711,18 +774,15 @@ function Write-Status($state, $message, $family, [switch]$KeepSubStep) { # 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 } - } + if ($MetadataRefresh -and $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 + state = $curState + message = $curMsg family = $famField progress = -1 usage5h = $usageField @@ -732,11 +792,11 @@ function Write-Status($state, $message, $family, [switch]$KeepSubStep) { step = $stepField total = $totalField detail = $detailField - ts = (Get-Date).ToString($FMT_O) source = $sourceField - } | ConvertTo-Json -Compress - [System.IO.File]::WriteAllText($tmp, $payload, [System.Text.Encoding]::UTF8) - Move-Item -Path $tmp -Destination $statusFile -Force + } + # `ts` and the monotonic `version` are stamped by the store, from the + # same snapshot this payload was built from. + Write-StatusSnapshot -Payload $payload -Previous $prev } # Get-5hUsage 现在定义在 scripts/lib/Get-5hUsage.ps1(detector 顶部 dot-source 了它). @@ -812,10 +872,11 @@ function Get-TodoProgress { } } -function Read-StatusObj { - if (!(Test-Path $statusFile)) { return $null } - try { return ([System.IO.File]::ReadAllText($statusFile) | ConvertFrom-Json) } catch { return $null } -} +# status.json is read through the store lib now (Read-StatusSnapshot), +# which resolves the same path but shares the locking protocol with the +# writers. The local Read-StatusObj is gone: it read the file without +# the lock, which is what let a caller act on a snapshot that another +# process had already replaced. # === 主循环 === if (-not $Once) { @@ -882,44 +943,59 @@ try { } if ($inferred) { - $cur = Read-StatusObj - $curSource = if ($cur) { [string]$cur.source } else { '' } - $curState = if ($cur) { [string]$cur.state } else { '' } $newState = [string]$inferred.state $newMsg = [string]$inferred.message + $logLine = $null - $isOwn = ($curSource -eq $S_DETECTOR) - $stateChanged = ($curState -ne $newState) - # 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 = '' - - if (-not $cur) { - $shouldWrite = $true - $reason = $R_NO_CURRENT - } elseif ($isOwn -and $stateChanged) { - $shouldWrite = $true - $reason = $R_OWN_CHANGED - } elseif ($isOwn -and ($cur.message -ne $newMsg) -and $newMsg) { - $shouldWrite = $true - $reason = $R_OWN_MSG - } elseif (-not $isOwn -and $isSettleNew) { - $shouldWrite = $true - $reason = $R_TAKEOVER - } + # The arbitration read and the write it authorises are ONE + # critical section (round-21 review hetaoBackend #1). Reading the + # current status outside the lock left a window in which a hook + # push was judged against a stale snapshot and then overwritten by + # the write that stale snapshot had authorised. + $lock = Enter-StatusLock + try { + $cur = Read-StatusSnapshot + $curSource = if ($cur) { [string]$cur.source } else { '' } + $curState = if ($cur) { [string]$cur.state } else { '' } + + $isOwn = ($curSource -eq $S_DETECTOR) + $stateChanged = ($curState -ne $newState) + # 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 = '' + + if (-not $cur) { + $shouldWrite = $true + $reason = $R_NO_CURRENT + } elseif ($isOwn -and $stateChanged) { + $shouldWrite = $true + $reason = $R_OWN_CHANGED + } elseif ($isOwn -and ($cur.message -ne $newMsg) -and $newMsg) { + $shouldWrite = $true + $reason = $R_OWN_MSG + } elseif (-not $isOwn -and $isSettleNew) { + $shouldWrite = $true + $reason = $R_TAKEOVER + } - if ($shouldWrite) { - Write-Status $inferred.state $inferred.message $inferred.family - if ($script:lastDetectedState -ne $newState) { - Log-Line "$newState :: $newMsg ($reason)" - $script:lastDetectedState = $newState + if ($shouldWrite) { + Write-StatusUnderLock $newState $newMsg $inferred.family + if ($script:lastDetectedState -ne $newState) { + $script:lastDetectedState = $newState + $logLine = "$newState :: $newMsg ($reason)" + } } + } finally { + Exit-StatusLock $lock } + # Appended outside the critical section: the log write must not + # hold the lock that the other writer is waiting for. + if ($logLine) { Log-Line $logLine } } # 4) 5h 用量:每 60s 刷一次,刷新后若数字变化就写 status.json(让 widget 看到) @@ -930,12 +1006,15 @@ try { $curMs = $script:plan5hResetMs $usageChanged = ($prevPct -ne $curPct) -or ($prevMs -ne $curMs) -and ($null -ne $curPct) if ($usageChanged) { - $curForUsage = Read-StatusObj - $sForU = if ($curForUsage) { [string]$curForUsage.state } else { $S_IDLE } - $mForU = if ($curForUsage) { [string]$curForUsage.message } else { '' } - $fForU = if ($curForUsage -and $curForUsage.PSObject.Properties['family']) { $curForUsage.family } else { $null } - # 元数据刷新:原样写回当前状态,必须保留 sub-step(-KeepSubStep) - Write-Status $sForU $mForU $fForU -KeepSubStep + # Metadata refresh: restate the current state and refresh the + # numbers. The snapshot it restates is read INSIDE Write-Status, + # under the same lock as the write, so a hook push landing here + # is either fully seen or fully missed -- never half. The old code + # read the file here, read it again inside Write-Status, and wrote + # the result back over the whole file, so a hook write landing + # between the two reads was clobbered with stale values + # (round-21 review hetaoBackend #1). + Write-Status -MetadataRefresh Log-Line ("5h usage refreshed: remaining=" + $curPct + "% resetMs=" + $curMs) } @@ -945,12 +1024,7 @@ try { $curTodoPct = if ($null -ne $curTodoData) { $curTodoData.percent } else { $null } $todoChanged = ($prevTodoPct -ne $curTodoPct) if ($todoChanged) { - $curForTodo = Read-StatusObj - $sForT = if ($curForTodo) { [string]$curForTodo.state } else { $S_IDLE } - $mForT = if ($curForTodo) { [string]$curForTodo.message } else { '' } - $fForT = if ($curForTodo -and $curForTodo.PSObject.Properties['family']) { $curForTodo.family } else { $null } - # 元数据刷新:原样写回当前状态,必须保留 sub-step(-KeepSubStep) - Write-Status $sForT $mForT $fForT -KeepSubStep + Write-Status -MetadataRefresh $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 1b9f7d71..bb8ec036 100644 --- a/plugins/antianqi/mcode-island/notify-island.ps1 +++ b/plugins/antianqi/mcode-island/notify-island.ps1 @@ -20,9 +20,22 @@ $ErrorActionPreference = 'Stop' [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 chcp 65001 | Out-Null +# status.json 的跨进程协议(round-21 review hetaoBackend #1)。 +# +# 这个文件和 detector 守护进程是两个独立进程,都会写 status.json。之前 +# 两边完全没有互斥:detector 读了一次 status.json,hook 写了一次, +# detector 再把基于旧读写回的 payload 盖上去 —— hook 推的 +# step/total/detail 要么被混进一份陈旧 payload,要么直接丢失。两边还 +# 共用同一个 "status.json.tmp" 暂存路径,一个进程可能把另一个进程写到 +# 一半的文件 rename 成正式文件。 +# +# 共享的 store 库给两边同一把跨进程锁、同一份快照、同一个暂存文件。 +. "$PSScriptRoot/scripts/lib/Status-Store.ps1" + $configDir = Join-Path $env:APPDATA 'mcode-island' if (!(Test-Path $configDir)) { New-Item -ItemType Directory -Path $configDir -Force | Out-Null } -$statusFile = Join-Path $configDir 'status.json' +# status.json 的路径由 store 库统一解析(Get-StatusFilePath),这里不再 +# 维护第二份,避免两个来源对不上。 $callerFile = Join-Path $configDir 'caller.json' # 找当前 console 关联的窗口(这是 Windows Terminal 里那个具体 tab 的 HWND) @@ -112,6 +125,7 @@ $ts = (Get-Date).ToString('o') # 写 status.json(widget 轮询用) # 加 source='agent' 让 detector 识别这是 agent 主动推的,保留不被 working/thinking/done # 覆盖(detector 推 idle/error 兜底仍然可以接管,符合 takeover 语义)。 +# ts 和 version 由 store 统一盖章,所以这里不写。 $payload = [PSCustomObject]@{ state = $State message = $Message @@ -119,9 +133,8 @@ $payload = [PSCustomObject]@{ step = $Step total = $Total detail = $Detail - ts = $ts source = 'agent' -} | ConvertTo-Json -Compress +} # 写 caller.json(widget 点击切回用) $myPid = $PID @@ -138,13 +151,22 @@ $callerData = [PSCustomObject]@{ } # 原子写 -$tmpStatus = "$statusFile.tmp" +# +# 两个文件都在同一把跨进程锁里写。caller.json 只有一个归属方(agent 侧), +# 但两个 hook 进程可能同时跑,所以它的暂存路径之前和 status.json 一样是 +# 共用的 —— 一个 hook 会把另一个写到一半的文件 rename 到位。现在锁把 +# 两个写入方串起来了,固定暂存名也就安全了(不会攒出一堆孤儿文件)。 $tmpCaller = "$callerFile.tmp" try { - [System.IO.File]::WriteAllText($tmpStatus, $payload, [System.Text.Encoding]::UTF8) - Move-Item -Path $tmpStatus -Destination $statusFile -Force - [System.IO.File]::WriteAllText($tmpCaller, ($callerData | ConvertTo-Json -Compress), [System.Text.Encoding]::UTF8) - Move-Item -Path $tmpCaller -Destination $callerFile -Force + $lock = Enter-StatusLock + try { + $prev = Read-StatusSnapshot + Write-StatusSnapshot -Payload $payload -Previous $prev + [System.IO.File]::WriteAllText($tmpCaller, ($callerData | ConvertTo-Json -Compress), [System.Text.Encoding]::UTF8) + Move-Item -Path $tmpCaller -Destination $callerFile -Force + } finally { + Exit-StatusLock $lock + } Write-Output "OK: $State - $Message" if ($focusInfo) { Write-Output (" (caller via " + $focusInfo.source + ": " + $focusInfo.targetExe + " PID=" + $focusInfo.targetPid + " hwnd=" + $focusInfo.targetHwnd + ")") diff --git a/plugins/antianqi/mcode-island/scripts/lib/Status-Store.ps1 b/plugins/antianqi/mcode-island/scripts/lib/Status-Store.ps1 new file mode 100644 index 00000000..9e653036 --- /dev/null +++ b/plugins/antianqi/mcode-island/scripts/lib/Status-Store.ps1 @@ -0,0 +1,174 @@ +# Status-Store.ps1 -- the cross-process protocol for status.json. +# +# Two independent processes write this file: +# +# - the detector daemon (mcode-status-detect.ps1), which restates the +# current state every 60s and asserts a new state whenever it infers +# one from the session log; +# - the agent hooks, through notify-island.ps1, which runs as a child +# process for every tool call. +# +# They used to have no mutual exclusion at all. The detector read +# status.json, the hook wrote it, and the detector then overwrote the +# hook's write with a payload built from the older read -- so the hook's +# step/total/detail were either mixed into a stale payload or lost +# outright. Both writers also staged through the same "status.json.tmp" +# path, so one could rename the other's half-written file into place. +# +# This file is the one place where that protocol lives: +# +# Enter-StatusLock / Exit-StatusLock +# An exclusive cross-process lock on a sibling lock file. It is a +# real OS file lock (FileShare.None), not a flag file whose +# presence we poll: a flag file has the same check-then-act hole +# the payload has. If a holder dies, the OS releases the handle. +# +# Read-StatusSnapshot +# One read of the whole status object. Callers read once and reuse +# that snapshot. Reading again after another writer had a chance to +# run is what produced the mixed-generation payloads. +# +# Write-StatusSnapshot +# Atomic replace (staging file + rename) that also stamps the +# monotonic `version` counter and the `ts` timestamp. +# +# ASCII only on purpose. Windows PowerShell 5.1 decodes a BOM-less +# script with the system ANSI codepage, and this file is dot-sourced by +# the detector, which runs under 5.1 on a zh-CN host (round-20 #4). + +function Get-StatusStoreDir { + return (Join-Path $env:APPDATA 'mcode-island') +} + +function Get-StatusFilePath { + return (Join-Path (Get-StatusStoreDir) 'status.json') +} + +function Get-StatusLockPath { + return (Join-Path (Get-StatusStoreDir) 'status.lock') +} + +# Takes the exclusive cross-process lock and returns the open handle. +# The caller MUST pass it to Exit-StatusLock, ideally in a finally. +# +# Not re-entrant by design: a nested take would deadlock against +# itself, because the OS refuses a second open of a FileShare.None file +# even from the same process. Code that needs to write while already +# holding the lock calls the *UnderLock helper instead of taking it +# twice. +function Enter-StatusLock { + param( + [int]$TimeoutMs = 3000, + [int]$RetryMs = 15 + ) + $dir = Get-StatusStoreDir + if (!(Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } + $lockPath = Get-StatusLockPath + $deadline = (Get-Date).AddMilliseconds($TimeoutMs) + while ($true) { + try { + # FileShare.None: the OS refuses any other open of this file until + # this handle is closed. That is the mutual exclusion we need, + # across processes that know nothing about each other. + return [System.IO.File]::Open( + $lockPath, + [System.IO.FileMode]::OpenOrCreate, + [System.IO.FileAccess]::ReadWrite, + [System.IO.FileShare]::None) + } catch { + if ((Get-Date) -ge $deadline) { + throw "status.json lock timeout after $TimeoutMs ms: $lockPath" + } + Start-Sleep -Milliseconds $RetryMs + } + } +} + +function Exit-StatusLock { + param($Handle) + if ($null -eq $Handle) { return } + try { $Handle.Dispose() } catch { } +} + +function Read-StatusSnapshot { + $file = Get-StatusFilePath + if (!(Test-Path $file)) { return $null } + try { + return ([System.IO.File]::ReadAllText($file) | ConvertFrom-Json) + } catch { + return $null + } +} + +# Replaces status.json with $Payload. +# +# $Previous is the snapshot the payload was built from. It is used for +# the version counter, so that the counter advances exactly once per +# write and two writers can never mint the same version. Pass the same +# snapshot the payload was derived from -- not a fresh read -- otherwise +# the counter is derived from a generation that may already be stale. +# +# The caller must hold the lock. +function Write-StatusSnapshot { + param( + [Parameter(Mandatory = $true)]$Payload, + $Previous = $null + ) + $dir = Get-StatusStoreDir + if (!(Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } + $file = Get-StatusFilePath + + if ($null -eq $Previous) { $Previous = Read-StatusSnapshot } + + $version = 1 + if ($null -ne $Previous -and $Previous.PSObject.Properties['version']) { + $version = ([int]$Previous.version) + 1 + } + + $ordered = [ordered]@{} + if ($Payload -is [System.Collections.IDictionary]) { + foreach ($k in $Payload.Keys) { $ordered[$k] = $Payload[$k] } + } else { + foreach ($p in $Payload.PSObject.Properties) { $ordered[$p.Name] = $p.Value } + } + $ordered['ts'] = (Get-Date).ToString('o') + $ordered['version'] = $version + + $json = [PSCustomObject]$ordered | ConvertTo-Json -Compress + + # Staging file + rename. The staging name is fixed rather than + # per-PID: every writer holds the lock, so only one staging file is + # ever live, and a writer that dies between the two steps leaves a + # file the next writer overwrites instead of a pile of orphans. + $tmp = "$file.tmp" + [System.IO.File]::WriteAllText($tmp, $json, [System.Text.Encoding]::UTF8) + + # The replace is retried because it can lose a sharing violation. + # + # MoveFileEx with REPLACE_EXISTING fails when the target is open by a + # reader that did not share delete. The widget polls status.json + # every 400 ms with Get-Content, and Get-Content opens with + # FileShare.Read -- no delete. So a write that lands while the widget + # happens to hold the file open throws "the file already exists" and + # the update is lost: the staging file survives, the target keeps the + # old payload, and the caller sees an exception. + # + # The reader holds the file for microseconds, so one short retry + # clears it. The staging file is untouched by a failed move, so the + # retry re-moves the same bytes. + $attempt = 0 + while ($true) { + try { + Move-Item -Path $tmp -Destination $file -Force + break + } catch { + $attempt++ + if ($attempt -ge 20) { throw } + Start-Sleep -Milliseconds 25 + } + } + # No return value on purpose. The detector calls this from inside its + # main loop, and a returned version would be echoed to the daemon's + # stdout on every write. The version is in the file; a caller that + # needs it reads it from there. +} diff --git a/plugins/antianqi/mcode-island/scripts/smoke.mjs b/plugins/antianqi/mcode-island/scripts/smoke.mjs index 9f45fd1d..34fddb60 100644 --- a/plugins/antianqi/mcode-island/scripts/smoke.mjs +++ b/plugins/antianqi/mcode-island/scripts/smoke.mjs @@ -1141,7 +1141,110 @@ const main = async () => { } } - // 11. Non-ASCII .ps1 files carry a UTF-8 BOM (round-20 #4). + // 11. The status.json write path is atomic across processes + // (round-21 review hetaoBackend #1). + // + // Contract: the detector and the agent hooks are two independent + // processes writing the same file. They must share one lock, one + // snapshot and one staging path, or a write that lands between a + // reader's read and its write is lost. + // + // These are the static drift locks. The behavioral proof -- two real + // PowerShell processes interleaving -- lives in + // scripts/test-substep-progress.mjs, section 7, because smoke does + // not execute PowerShell. + { + const storePath = join(PLUGIN_ROOT, 'scripts', 'lib', 'Status-Store.ps1'); + const detectPath = join(PLUGIN_ROOT, 'mcode-status-detect.ps1'); + const notifyPath = join(PLUGIN_ROOT, 'notify-island.ps1'); + + const storeExists = await exists(storePath); + const store = storeExists ? await readFile(storePath, 'utf8') : ''; + const detect = await readFile(detectPath, 'utf8'); + const notify = await readFile(notifyPath, 'utf8'); + + if (!storeExists) { + out('FAIL', 'Status-Store.ps1: the shared status.json protocol lib is missing'); + } else { + // The lock must be a real OS file lock, not a flag file whose + // presence we poll: a flag file has the same check-then-act + // hole the payload has. + if (/FileShare\]::None/.test(store) || /FileShare::None/.test(store)) { + out('PASS', 'Status-Store.ps1: the cross-process lock uses an exclusive OS file lock'); + } else { + out('FAIL', 'Status-Store.ps1: the lock does not use FileShare.None, so it is advisory and does not exclude another writer'); + } + + if (/function\s+Read-StatusSnapshot/.test(store) && /function\s+Write-StatusSnapshot/.test(store)) { + out('PASS', 'Status-Store.ps1: exposes the shared read and write primitives'); + } else { + out('FAIL', 'Status-Store.ps1: the shared read/write primitives are missing'); + } + + // The version counter is what lets a reader tell generations + // apart. It has to be stamped on every write. + if (/\[ordered\]@\{\}/.test(store) && /\$ordered\['version'\]/.test(store)) { + out('PASS', 'Status-Store.ps1: every write stamps the monotonic version'); + } else { + out('FAIL', 'Status-Store.ps1: writes do not stamp a version, so a reader cannot tell generations apart'); + } + + // The staging path must be shared-but-locked, not per-writer: + // two writers staging through the same path is what let one + // rename the other's half-written file into place. + if (/Move-Item -Path \$tmp -Destination \$file -Force/.test(store)) { + out('PASS', 'Status-Store.ps1: the status file is replaced through a staging file'); + } else { + out('FAIL', 'Status-Store.ps1: the status file is not replaced through a staging file'); + } + } + + // The detector must route every write through the shared lib. + if (/Status-Store\.ps1/.test(detect)) { + out('PASS', 'mcode-status-detect.ps1: dot-sources the shared status store lib'); + } else { + out('FAIL', 'mcode-status-detect.ps1: does not dot-source the shared status store lib'); + } + + // The whole point: ONE read per write. A second read inside the + // locked region can still observe a generation the payload was + // not built from. + const writeStatusBody = detect.match(/function Write-StatusUnderLock[\s\S]*?\n}\n/); + if (!writeStatusBody) { + out('FAIL', 'mcode-status-detect.ps1: Write-StatusUnderLock not found'); + } else { + const reads = (writeStatusBody[0].match(/Read-StatusSnapshot/g) || []).length; + if (reads === 1) { + out('PASS', 'mcode-status-detect.ps1: the write path reads status.json exactly once'); + } else { + out('FAIL', `mcode-status-detect.ps1: the write path reads status.json ${reads} times; a second read can observe a different generation than the payload was built from`); + } + } + + if (/Enter-StatusLock/.test(detect) && /Exit-StatusLock/.test(detect)) { + out('PASS', 'mcode-status-detect.ps1: takes and releases the cross-process lock'); + } else { + out('FAIL', 'mcode-status-detect.ps1: does not take/release the cross-process lock around the write'); + } + + // Both writers must use the same protocol, or the lock excludes + // nobody. + if (/Status-Store\.ps1/.test(notify) && /Enter-StatusLock/.test(notify) && /Exit-StatusLock/.test(notify)) { + out('PASS', 'notify-island.ps1: the agent-side writer uses the same locked protocol'); + } else { + out('FAIL', 'notify-island.ps1: the agent-side writer does not use the shared locked protocol, so the lock excludes nobody'); + } + + // A per-writer staging path for the agent side would be a second + // protocol; a SHARED one is the bug. + if (!/"\$statusFile\.tmp"/.test(notify)) { + out('PASS', 'notify-island.ps1: does not stage status.json through a shared path'); + } else { + out('FAIL', 'notify-island.ps1: stages status.json through $statusFile.tmp, the same path the detector used'); + } + } + + // 12. 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. diff --git a/plugins/antianqi/mcode-island/scripts/test-substep-progress.mjs b/plugins/antianqi/mcode-island/scripts/test-substep-progress.mjs index dc8fd475..bebe5200 100644 --- a/plugins/antianqi/mcode-island/scripts/test-substep-progress.mjs +++ b/plugins/antianqi/mcode-island/scripts/test-substep-progress.mjs @@ -11,11 +11,11 @@ // // Exit 0 = all passed. Any failure exits 1. -import { readFileSync, existsSync, mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { readFileSync, existsSync, mkdtempSync, writeFileSync, rmSync, mkdirSync } 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'; +import { execFileSync, spawn } from 'node:child_process'; const HERE = dirname(fileURLToPath(import.meta.url)); const PLUGIN_ROOT = dirname(HERE); @@ -65,7 +65,7 @@ const PS_BIN = resolvePs(); // cannot leave fixtures behind for the next one to read. const TMP = mkdtempSync(join(tmpdir(), 'island-substep-')); -function runPs(script) { +function runPs(script, extraEnv) { 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 @@ -77,7 +77,11 @@ function runPs(script) { 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'] }); + { + encoding: 'buffer', + stdio: ['ignore', 'pipe', 'pipe'], + env: extraEnv ? { ...process.env, ...extraEnv } : process.env, + }); let buf = out; if (buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf) { buf = buf.subarray(3); @@ -85,6 +89,83 @@ function runPs(script) { return buf.toString('utf8'); } +/** + * Start a PowerShell child and resolve with its exit code and output. + * Used where a test needs two processes running at the same time; runPs + * is synchronous and cannot express that. + */ +function spawnPs(script, extraEnv) { + const file = join(TMP, `spawn-${Math.random().toString(36).slice(2)}.ps1`); + writeFileSync(file, '\uFEFF' + '[Console]::OutputEncoding = [System.Text.Encoding]::UTF8\n' + script, 'utf8'); + const child = spawn(PS_BIN, ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', file], { + env: { ...process.env, ...(extraEnv || {}) }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let out = ''; + let err = ''; + child.stdout.on('data', (d) => { out += d.toString('utf8'); }); + child.stderr.on('data', (d) => { err += d.toString('utf8'); }); + return new Promise((resolve) => { + child.on('close', (code) => resolve({ code, out, err })); + }); +} + +/** + * An isolated %APPDATA% so a test can exercise the real code paths that + * resolve %APPDATA%\mcode-island\status.json without touching the + * developer's live island. + */ +function makeIsolatedAppData(tag) { + const dir = join(TMP, `appdata-${tag}-${Math.random().toString(36).slice(2)}`); + mkdirSync(join(dir, 'mcode-island'), { recursive: true }); + return dir; +} + +/** + * Read a JSON file the way the widget reads it. + * + * status.json is written with [System.Text.Encoding]::UTF8, which emits + * a BOM. Every PowerShell reader strips it (ReadAllText detects it, + * Get-Content -Encoding UTF8 tolerates it); JSON.parse does not, and + * would report a syntax error on a file that is perfectly valid. + */ +function readJsonFile(path) { + const raw = readFileSync(path, 'utf8'); + return JSON.parse(raw.replace(/^\uFEFF/, '')); +} + +/** + * Strip PowerShell comments so a static check looks at CODE, not at prose. + * + * The detector documents removed identifiers in its comments; a check for + * "this identifier must not appear" would otherwise trip over the + * comment that explains why it was removed. + */ +function stripPsComments(src) { + return src.split('\n').map((l) => l.replace(/#.*$/, '')).join('\n'); +} + +/** + * Extract a balanced-brace block from PowerShell source, starting at the + * first occurrence of `marker`. Returns null when the marker is absent + * or the braces do not balance. + */ +function detectCodeBlock(src, marker) { + const start = src.indexOf(marker); + if (start < 0) return null; + const open = src.indexOf('{', start); + if (open < 0) return null; + let depth = 0; + for (let i = open; i < src.length; i++) { + if (src[i] === '{') depth++; + else if (src[i] === '}') { + depth--; + if (depth === 0) return src.slice(start, i + 1); + } + } + return null; +} + /** 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 @@ -296,65 +377,155 @@ Build-DisplayMessage -Message '${msg.replace(/'/g, "''")}' -Step ${step} -Total } // --------------------------------------------------------------------------- -// 3. Detector preserves sub-step fields across a rewrite (review #2) +// 3. The metadata refresh preserves the sub-step fields (round-21 #1) // --------------------------------------------------------------------------- +// +// This section runs the REAL Write-Status against a REAL status.json in +// an isolated %APPDATA%, instead of extracting a fragment of the +// function body and stubbing the reader. +// +// The old harness stubbed Read-StatusObj and pasted the decision block +// into a synthetic script. That could not tell a working implementation +// from one that read the file twice, because the second read was exactly +// what the stub hid -- and a second read IS the bug the review named: +// "Write-Status -KeepSubStep performs a second independent read for +// step/detail/source before overwriting the file". +// +// The contract: +// - a metadata refresh restates the current state, preserves the +// sub-step fields and preserves the state owner; +// - a state-inference write resets the sub-step fields and claims +// ownership; +// - both take exactly ONE snapshot and write under the shared lock; +// - every write advances the monotonic version exactly once. + +const storeLibPath = join(PLUGIN_ROOT, 'scripts', 'lib', 'Status-Store.ps1'); +const storeLibRef = storeLibPath.replace(/'/g, "''"); -// 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 detectCode = stripPsComments(detect); + const lines = detectCode.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. + const writeStatusUnderLockFn = extractFn(detect, 'Write-StatusUnderLock'); + + check('the status store lib exists', existsSync(storeLibPath), `${storeLibPath} not found`); + + // The lock has to be taken and released around the whole write, and + // the snapshot has to be read exactly once. A second read is still a + // second read: it can observe a generation the payload was not built + // from, and that is what produced the mixed-generation payloads. + check('Write-Status takes the cross-process lock', + /Enter-StatusLock/.test(writeStatusFn), + 'Write-Status writes status.json without taking the lock, so a hook write can land between its read and its write'); + check('Write-Status releases the lock', + /Exit-StatusLock/.test(writeStatusFn), + 'Write-Status takes the lock but never releases it; every later writer would block until the lock timed out'); + + const readsInWriter = (writeStatusFn.match(/Read-StatusSnapshot/g) || []).length; + const readsInBody = (writeStatusUnderLockFn.match(/Read-StatusSnapshot/g) || []).length; + eq('the write path reads status.json exactly once', readsInWriter + readsInBody, 1); + eq('the snapshot is not read outside the locked body', readsInWriter, 0); + eq('the locked body takes the snapshot once', readsInBody, 1); + + check('the detector no longer has an unlocked reader', + !/Read-StatusObj/.test(stripPsComments(detect)), + 'Read-StatusObj read status.json without the lock; it is the read that made a lost update possible'); + + // The two layers have to be bound together. Section 7 proves the + // PROTOCOL (two real processes, one file, no lost write) using + // hand-written writers, because the deterministic choreography needs + // the read to be separable from the write -- and the real code + // deliberately does not separate them. These checks are what say + // that the real detector code goes through that protocol. + const arbitration = detectCodeBlock(detectCode, 'if ($inferred) {'); + check('the arbitration block takes the lock', + !!arbitration && /Enter-StatusLock/.test(arbitration), + 'the arbitration read and the write it authorises are not one critical section, so a hook push can be judged against a stale snapshot and then overwritten'); + check('the arbitration block writes through the locked body', + !!arbitration && /Write-StatusUnderLock/.test(arbitration), + 'the arbitration path does not write through Write-StatusUnderLock, so it is not covered by the lock'); + const unlockedWrites = lines.filter((l) => + /Write-StatusUnderLock/.test(l) + && !/^\s*function\s+Write-StatusUnderLock/.test(l) + && !/Write-StatusUnderLock\s+\$state/.test(l) + && !/Write-StatusUnderLock\s+\$newState/.test(l)); + eq('every write goes through the locked body', unlockedWrites.length, 0); + + // The call sites must differ. A switch nobody passes is the dead-code + // shape this suite exists to catch. // - // 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 = ` + // Four call sites exist, and they are three different things: + // - the inference path, which must NOT preserve the sub-step; + // - the two metadata refreshes, which must; + // - the wrapper's own forward, which passes the switch through + // and is not a metadata site. + // Counting "3 call sites" the way the old suite did no longer + // describes anything, so each kind is pinned separately. + const refreshes = lines + .map((l, i) => ({ l, n: i + 1 })) + .filter(({ l }) => /^\s*Write-Status\s+-MetadataRefresh\s*$/.test(l)); + const infers = lines + .map((l, i) => ({ l, n: i + 1 })) + .filter(({ l }) => /^\s*Write-StatusUnderLock\s+\$newState/.test(l)); + const forwards = lines + .map((l, i) => ({ l, n: i + 1 })) + .filter(({ l }) => /^\s*Write-StatusUnderLock\s+\$state\s+\$message\s+\$family\s+-MetadataRefresh:/.test(l)); + + eq('the 5h-usage and todo call sites pass -MetadataRefresh', refreshes.length, 2); + check('the state-inference call site does NOT pass -MetadataRefresh', + infers.length === 1 && !/-MetadataRefresh/.test(infers[0].l), + `inference call site must clear the stale sub-step (found ${infers.length})`); + eq('the lock wrapper forwards the switch exactly once', forwards.length, 1); + + // The harness: the real functions, the real store lib, an isolated + // %APPDATA%. $S_IDLE / $S_DETECTOR are script-scope constants in the + // detector; the extracted functions need them to resolve. + const buildHarness = (call) => ` $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" +. '${storeLibRef}' +$S_IDLE = 'idle' +$S_DETECTOR = 'detector' +$script:plan5hRemainingPct = $null +$script:plan5hResetMs = $null +$script:plan5hTodoData = $null +${writeStatusFn} +${writeStatusUnderLockFn} +${call} +'DONE' `; - 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|'); - } + const appdata = makeIsolatedAppData('write-status'); + const statusPath = join(appdata, 'mcode-island', 'status.json'); + const seed = { + state: 'working', message: 'bash', family: 'shell', + step: 4, total: 12, detail: 'running tests', source: 'agent', version: 3, + }; + + writeFileSync(statusPath, JSON.stringify(seed), 'utf8'); + const refreshOut = runPs(buildHarness('Write-Status -MetadataRefresh'), { APPDATA: appdata }); + // The marker is what makes this check mean something: without it an + // empty output would look exactly like a clean run. + check('the metadata refresh ran to completion', + /DONE/.test(refreshOut) && !/Exception/i.test(refreshOut), + `metadata refresh did not complete: ${JSON.stringify(refreshOut.trim())}`); + // The detector calls the write path from inside its main loop, and + // the daemon's stdout goes to a log file. A write path that returns + // its version therefore echoes a bare number on every write. This + // check failed once for exactly that reason. + eq('the write path does not echo anything to the caller', refreshOut.trim(), 'DONE'); + + const afterRefresh = readJsonFile(statusPath); + eq('metadata refresh preserves step', afterRefresh.step, 4); + eq('metadata refresh preserves total', afterRefresh.total, 12); + eq('metadata refresh preserves detail', afterRefresh.detail, 'running tests'); + eq('metadata refresh preserves the state owner', afterRefresh.source, 'agent'); + eq('metadata refresh restates the state', afterRefresh.state, 'working'); + eq('metadata refresh restates the message', afterRefresh.message, 'bash'); + eq('metadata refresh preserves the family', afterRefresh.family, 'shell'); + eq('metadata refresh advances the version exactly once', afterRefresh.version, 4); } // --------------------------------------------------------------------------- @@ -409,91 +580,61 @@ ${preserveMatch[0].replace(/\$payload\s*=$/, '')} // // 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'); +// +// Both halves run the real Write-Status against a real status.json in an +// isolated %APPDATA% -- no extracted fragment, no stubbed reader. The +// call-site wiring is asserted in section 3. +if (PS_BIN) { + const appdata = makeIsolatedAppData('state-inference'); + const statusPath = join(appdata, 'mcode-island', 'status.json'); - const harness = ` + const buildHarness = (call) => ` $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. +. '${storeLibRef}' +$S_IDLE = 'idle' $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" +$script:plan5hRemainingPct = $null +$script:plan5hResetMs = $null +$script:plan5hTodoData = $null +${extractFn(readFileSync(join(PLUGIN_ROOT, 'mcode-status-detect.ps1'), 'utf8'), 'Write-Status')} +${extractFn(readFileSync(join(PLUGIN_ROOT, 'mcode-status-detect.ps1'), 'utf8'), 'Write-StatusUnderLock')} +${call} +'DONE' `; - 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}`); + // 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', version: 7, + }; + + writeFileSync(statusPath, JSON.stringify(stale), 'utf8'); + runPs(buildHarness("Write-Status 'done' 'Bash ok' 'shell'"), { APPDATA: appdata }); + const inferred = readJsonFile(statusPath); + + eq('state-inference write clears the stale sub-step (step)', inferred.step, -1); + eq('state-inference write clears the stale sub-step (total)', inferred.total, -1); + eq('state-inference write clears the stale sub-step (detail)', inferred.detail, ''); + // 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". + eq('state-inference write claims ownership', inferred.source, 'detector'); + eq('state-inference write advances the version exactly once', inferred.version, 8); + eq('state-inference write keeps the family it was given', inferred.family, 'shell'); + + // The no-prior case must still default sanely, otherwise a fresh + // install would render "step -1/-1" and claim a state nobody set. + const freshDir = makeIsolatedAppData('state-inference-fresh'); + runPs(buildHarness("Write-Status 'working' 'bash' 'shell'"), { APPDATA: freshDir }); + const fresh = readJsonFile(join(freshDir, 'mcode-island', 'status.json')); + eq('a fresh install starts at version 1', fresh.version, 1); + eq('a fresh install defaults the sub-step to unset', `${fresh.step}|${fresh.total}|${fresh.detail}`, '-1|-1|'); } // --------------------------------------------------------------------------- @@ -624,6 +765,214 @@ foreach ($c in $cases) { } } +// --------------------------------------------------------------------------- +// 7. Two writers, one file (round-21 review hetaoBackend #1) +// --------------------------------------------------------------------------- +// +// The review: "The loop reads old state/message, then Write-Status +// -KeepSubStep performs a second independent read for step/detail/source +// before overwriting the file. An agent write between the reads produces +// a mixed-generation payload, or a write after the second read is lost." +// +// This is the regression for exactly that sentence, with two real +// PowerShell processes: +// +// writer A the detector's metadata refresh -- take the lock, read, +// then write a payload merged from that read +// writer B the agent hook's push -- take the lock, write a full +// payload +// +// The choreography is what makes it deterministic. A signals that it has +// read, then waits for B to signal that it is ABOUT to write. B's signal +// is published BEFORE B takes the lock, so: +// +// with the lock A holds it, B blocks, A writes, B writes last +// -> the file ends up carrying B's values +// without the lock B writes immediately, A writes 400 ms later from +// its stale read +// -> the file ends up carrying A's stale values and +// B's write is gone +// +// A payload that happens to look plausible is not enough. The version +// counter is a second, independent witness: both writers derive it from +// the snapshot they read, so two writers that read the same generation +// mint the same version. That collision is visible even when the +// surviving payload reads fine. +if (PS_BIN) { + const appdata = makeIsolatedAppData('two-writers'); + const storeDir = join(appdata, 'mcode-island'); + const statusPath = join(storeDir, 'status.json'); + const markerRead = join(storeDir, 'a-read'); + const markerAttempt = join(storeDir, 'b-attempt'); + + writeFileSync(statusPath, JSON.stringify({ + state: 'idle', message: '', step: 1, total: 3, detail: 'seed', + source: 'agent', version: 0, + }), 'utf8'); + + const writerA = ` +$ErrorActionPreference = 'Stop' +. '${storeLibRef}' +$lock = Enter-StatusLock +try { + $prev = Read-StatusSnapshot + Set-Content -Path $env:ISLAND_MARKER_READ -Value 'read' -Encoding ASCII + $deadline = (Get-Date).AddSeconds(20) + while (-not (Test-Path $env:ISLAND_MARKER_ATTEMPT)) { + if ((Get-Date) -ge $deadline) { throw 'writer B never signalled its intent to write' } + Start-Sleep -Milliseconds 20 + } + # Without the lock, B's write lands inside this window. With it, B is + # blocked and the sleep only makes the test slower. + Start-Sleep -Milliseconds 400 + $payload = [PSCustomObject]@{ + state = [string]$prev.state + message = [string]$prev.message + step = [int]$prev.step + total = [int]$prev.total + detail = [string]$prev.detail + source = [string]$prev.source + usage5h = 77 + } + Write-StatusSnapshot -Payload $payload -Previous $prev +} finally { + Exit-StatusLock $lock +} +'OK-A' +`; + + const writerB = ` +$ErrorActionPreference = 'Stop' +. '${storeLibRef}' +Set-Content -Path $env:ISLAND_MARKER_ATTEMPT -Value 'attempt' -Encoding ASCII +$lock = Enter-StatusLock +try { + $payload = [PSCustomObject]@{ + state = 'working' + message = 'hook push' + step = 5 + total = 6 + detail = 'hook detail' + source = 'agent' + } + Write-StatusSnapshot -Payload $payload +} finally { + Exit-StatusLock $lock +} +'OK-B' +`; + + const env = { + APPDATA: appdata, + ISLAND_MARKER_READ: markerRead, + ISLAND_MARKER_ATTEMPT: markerAttempt, + }; + + // A reader that polls the file while the writers run. The widget + // polls status.json every 400 ms with Get-Content, so a reader with + // the same habits is the one that has to be protected. + let torn = 0; + let reads = 0; + let polling = true; + const poll = (async () => { + while (polling) { + try { + const j = readJsonFile(statusPath); + reads++; + if (!j.state) torn++; + } catch (e) { + // A file that is momentarily absent is not a torn read; a + // payload that will not parse is. + if (e instanceof SyntaxError) torn++; + } + await new Promise((r) => setTimeout(r, 3)); + } + })(); + + const a = spawnPs(writerA, env); + // B must not start before A has taken its snapshot, or the ordering + // the whole test depends on is never established. + // + // The wait is BOUNDED. An unbounded spin here turns "writer A died" + // into a suite that hangs forever instead of a suite that fails, + // and a hanging test is worse than a failing one: it looks like a + // stuck CI job rather than a broken contract. + const markerDeadline = Date.now() + 30000; + while (!existsSync(markerRead)) { + if (Date.now() > markerDeadline) { + bad('writer A took its snapshot', + 'writer A never signalled that it had read the file within 30s; it probably died before it could take the lock'); + break; + } + await new Promise((r) => setTimeout(r, 10)); + } + const b = spawnPs(writerB, env); + // Bounded, for the same reason: a child that never exits must fail + // the test, not hang the suite. + const timeoutResult = { code: 'TIMEOUT', out: '', err: 'the writer did not finish within 60s' }; + const [ra, rb] = await Promise.race([ + Promise.all([a, b]), + new Promise((r) => setTimeout(() => r([timeoutResult, timeoutResult]), 60000)), + ]); + polling = false; + await poll; + + check('writer A (detector-style read-modify-write) completed', + ra.code === 0, + `exit ${ra.code}: ${(ra.err || ra.out).trim()}`); + check('writer B (agent-style push) completed', + rb.code === 0, + `exit ${rb.code}: ${(rb.err || rb.out).trim()}`); + + const final = readJsonFile(statusPath); + eq('the later write is not clobbered by a stale read (step)', final.step, 5); + eq('the later write is not clobbered by a stale read (detail)', final.detail, 'hook detail'); + eq('the later write is not clobbered by a stale read (state)', final.state, 'working'); + eq('every write advances the version exactly once', final.version, 2); + eq('a reader never observes a torn payload', torn, 0); + check('the reader actually observed the file', + reads > 0, + 'the poll loop never read status.json, so the torn-read check proved nothing'); +} + +// --------------------------------------------------------------------------- +// 8. The agent-side write takes the same lock (round-21 review #1) +// --------------------------------------------------------------------------- +// +// notify-island.ps1 is the other writer. If it staged through its own +// path without the lock, it could still rename the detector's half-written +// file into place, and the detector could still clobber it. The static +// checks below are the cheap ones; the behavioral proof is section 7. +{ + const notify = readFileSync(join(PLUGIN_ROOT, 'notify-island.ps1'), 'utf8'); + + check('notify-island dot-sources the shared store lib', + /Status-Store\.ps1/.test(notify), + 'notify-island.ps1 writes status.json without the shared protocol, so the two writers are not mutually excluded'); + check('notify-island takes the cross-process lock', + /Enter-StatusLock/.test(notify), + 'notify-island.ps1 writes status.json without taking the lock'); + check('notify-island releases the lock', + /Exit-StatusLock/.test(notify), + 'notify-island.ps1 takes the lock but never releases it'); + check('notify-island no longer stages through a shared status.json.tmp', + !/"\$statusFile\.tmp"/.test(notify), + 'notify-island.ps1 stages through $statusFile.tmp, the same path the detector used, so one could rename the other\'s half-written file into place'); + // The positive contract, not a spelling. The check above looks for + // one literal spelling of the shared path; a rewrite that spells the + // same path differently walks right past it, and the suite stays + // green while the bug is back. That is not hypothetical -- the + // negative-injection pass hit exactly this. So: status.json may + // only be written through the shared store. + const notifyCode = stripPsComments(notify); + check('notify-island writes status.json only through the shared store', + !/WriteAllText\([^)]*status/i.test(notifyCode) && !/Move-Item[^\n]*status\.json/i.test(notifyCode), + 'notify-island.ps1 writes status.json directly; the shared store owns that path, so the staging file and the lock stay in one place'); + check('notify-island does not write a raw payload with its own ts', + !/ts\s*=\s*\$ts[\s\S]{0,400}source\s*=\s*'agent'/.test(notify), + 'notify-island.ps1 stamps its own ts; the store owns ts and version so both writers agree on the format'); +} + // --------------------------------------------------------------------------- // summary // --------------------------------------------------------------------------- From 3df799df89ae232bad70fc4cdeb1fa495923948f Mon Sep 17 00:00:00 2001 From: antianqi Date: Thu, 22 Oct 2026 10:17:37 +0800 Subject: [PATCH 23/38] fix(mcode-island): PostToolUse classifies a structured tool result Round-21 review (hetaoBackend, 2026-10-09) blocking issue #2: "PostToolUse error classification stringifies the result and only recognizes textual error prefixes (io.minimax.mcode/hooks/scripts/post-tool-use.ps1:14-24). A structured {isError:true,...} result can be reported as `done`. Prefer the structured boolean and test object/string/null success and failure payloads through the real hook subprocess." The old code did: $s = [string]$result if ([string]::IsNullOrEmpty($s)) { $isError = $true } elseif ($s -match '^\s*(Error|ERROR|...|\[ERROR\])') { $isError = $true } A structured result stringifies to "@{isError=True; content=...}", which matches no prefix -- so a FAILED tool was reported as a SUCCESS and the pill showed green. The detector already read the structured boolean ($msg.isError -eq $true) from the session log, so the two producers of the same state disagreed about what a failure was. ## What changed io.minimax.mcode/hooks/scripts/_lib.ps1 New Test-ToolResultError. The order matters: the structured boolean wins, because it is what the producer actually set. The textual heuristic is only a fallback for a result that arrives as a bare string. A result that is a JSON string is parsed and its flag read, because a producer that serialises the result before handing it to the hook is still telling us the truth in a structured field. A null result and an empty string remain failures: the tool produced no result at all. io.minimax.mcode/hooks/scripts/post-tool-use.ps1 The inline classification is replaced by the shared helper. The header comment said "if the tool_result is empty or matches an error pattern", which is now no longer what the code does. ## Test evidence (negative-injection verified) 10 cases, every one of them through the REAL hook as a REAL child process, with a real event on stdin, reading back what the hook wrote to status.json. A function-body test would not prove the hook wires the classification to the state it pushes. structured failure {isError:true} -> error structured success {isError:false} -> done structured failure, nested {isError:true, content:{...}} -> error structured success, nested {isError:false, content:{...}} -> done string with an error prefix "Error: command not found" -> error plain string "all good" -> done JSON string with the flag '{"isError":true,...}' -> error JSON string with the flag '{"isError":false,...}' -> done null result null -> error empty string result "" -> error Each case also asserts the pushed message ("Bash failed" / "Bash ok"), because a correct state with a wrong message is still a broken pill. Each case runs in its own isolated %APPDATA%, so the assertion cannot read a previous case's status.json. test-substep-progress.mjs: 116 pass, 0 fail (was 84) 3 new negative-injection cases, each reverting one clause: N10 post-tool-use goes back to string-only classification -> 7 fail N11 the structured isError flag is ignored -> 4 fail N12 the structured failure is classified as a success -> 8 fail The harness around this had two of its own faults, both fixed rather than worked around: - The suite waited for writer A's marker with an UNBOUNDED spin. If A died first, the suite hung instead of failing, and a hanging test looks like a stuck CI job rather than a broken contract. The wait is bounded now, and the writers are bounded too. - The negative-injection harness ran the suite in the same process as itself, so the harness's own error handling could change how the suite's output was treated. It runs in its own job now, with a timeout, so a hung suite becomes a TIMEOUT verdict instead of a stuck harness. ## Design compliance no credentials : none added; the helper only inspects a flag no network : no network calls added no telemetry : none added no third-party svcs : no new dependencies; pure PowerShell cross-platform : no host paths introduced ASCII-only : the new helper adds one non-ASCII character that was already in the file (the error glyph), so the existing BOM stays required and unchanged --- .../io.minimax.mcode/hooks/scripts/_lib.ps1 | 47 ++++++++ .../hooks/scripts/post-tool-use.ps1 | 17 +-- .../scripts/test-substep-progress.mjs | 100 ++++++++++++++++++ 3 files changed, 151 insertions(+), 13 deletions(-) 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 772a922c..303313e1 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 @@ -85,6 +85,53 @@ function Test-IsSelfPush { return ($cmd -match 'notify-island\.ps1|wrap-tool\.ps1|island\\notify|island\\wrap') } +function Test-ToolResultError { + # Classifies a PostToolUse tool_result as a failure or a success. + # + # The detector already reads the structured boolean out of the session + # log (`$msg.isError -eq $true`). The hook path used to stringify the + # result and look for a textual error prefix, so a structured + # `{ isError: true }` came out as the string + # "@{isError=True; content=...}" -- which matches no prefix, so a + # FAILED tool was reported as a success and the pill showed green. + # + # Order matters. The structured boolean wins, because it is what the + # producer actually set. The textual heuristic is only a fallback for + # a result that arrives as a bare string. + param($Result) + + # No result at all: the tool produced nothing, which is a failure. + if ($null -eq $Result) { return $true } + + # A bare boolean is the flag itself. + if ($Result -is [bool]) { return $Result } + + # A structured result: read the flag the producer set. + if ($Result -isnot [string]) { + $flag = $Result.PSObject.Properties['isError'] + if ($flag) { return [bool]$flag.Value } + } + + $s = [string]$Result + if ([string]::IsNullOrEmpty($s)) { return $true } + + # A result that is a JSON string may still carry the structured flag. + $trimmed = $s.Trim() + if ($trimmed.StartsWith('{')) { + try { + $parsed = $trimmed | ConvertFrom-Json -ErrorAction Stop + if ($parsed -and $parsed.PSObject.Properties['isError']) { + return [bool]$parsed.isError + } + } catch { + # Not JSON after all. Fall through to the textual heuristic. + } + } + + if ($s -match '^\s*(Error|ERROR|✕|Error:|\[ERROR\])') { return $true } + return $false +} + function Format-ToolSummary { # Compact ": " used in pill messages. # Truncated to keep the WPF label single-line. 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 c1cc0a65..11cc8242 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,9 @@ # 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. +# Note: Fires after every tool call returns. The structured `isError` +# flag on the tool_result decides; a bare string falls back to a +# textual error prefix. 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". @@ -12,16 +12,7 @@ $evt = Read-HookStdin if (Test-IsSelfPush $evt) { exit 0 } $tool = if ($evt.tool_name) { [string]$evt.tool_name } else { 'tool' } -$result = $evt.tool_result -$isError = $false - -if ($null -eq $result) { - $isError = $true -} else { - $s = [string]$result - if ([string]::IsNullOrEmpty($s)) { $isError = $true } - elseif ($s -match '^\s*(Error|ERROR|✕|Error:|\[ERROR\])') { $isError = $true } -} +$isError = Test-ToolResultError $evt.tool_result # Format-ToolSummary 抽 detail,但要剥掉 "tool : " 前缀,只留后半段 $summary = Format-ToolSummary $evt diff --git a/plugins/antianqi/mcode-island/scripts/test-substep-progress.mjs b/plugins/antianqi/mcode-island/scripts/test-substep-progress.mjs index bebe5200..a6abe2ab 100644 --- a/plugins/antianqi/mcode-island/scripts/test-substep-progress.mjs +++ b/plugins/antianqi/mcode-island/scripts/test-substep-progress.mjs @@ -973,6 +973,106 @@ try { 'notify-island.ps1 stamps its own ts; the store owns ts and version so both writers agree on the format'); } +// --------------------------------------------------------------------------- +// 9. PostToolUse classifies a structured tool result (round-21 #2) +// --------------------------------------------------------------------------- +// +// The review: "PostToolUse error classification stringifies the result +// and only recognizes textual error prefixes. A structured +// {isError:true,...} result can be reported as `done`. Prefer the +// structured boolean and test object/string/null success and failure +// payloads through the real hook subprocess." +// +// The old code did `$s = [string]$result` and matched a textual prefix. +// A structured result stringifies to "@{isError=True; content=...}", +// which matches no prefix -- so a FAILED tool was reported as a +// SUCCESS and the pill showed green. +// +// These cases run the real hook as a real child process, with a real +// event on stdin, and read back what it wrote to status.json. A +// function-body test would not prove the hook wires the classification +// to the state it pushes. + +/** + * Run a hook script as a real child process with a JSON event on stdin. + * The hook reads its event from stdin and pushes the result to + * status.json, so the only honest way to test it is to feed it a + * payload and read back what it wrote. + */ +function runHookWithStdin(hookPath, eventJson, extraEnv) { + return new Promise((resolve) => { + const child = spawn(PS_BIN, ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', hookPath], { + env: { ...process.env, ...(extraEnv || {}) }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + let out = ''; + let err = ''; + child.stdout.on('data', (d) => { out += d.toString('utf8'); }); + child.stderr.on('data', (d) => { err += d.toString('utf8'); }); + child.on('close', (code) => resolve({ code, out, err })); + child.stdin.end(eventJson); + }); +} + +if (PS_BIN) { + const hookPath = join(PLUGIN_ROOT, 'io.minimax.mcode', 'hooks', 'scripts', 'post-tool-use.ps1'); + const postTool = readFileSync(hookPath, 'utf8'); + + check('post-tool-use delegates the classification to the shared helper', + /Test-ToolResultError/.test(postTool), + 'post-tool-use.ps1 still classifies the result inline, so a structured isError flag is ignored'); + check('post-tool-use no longer stringifies the tool result', + !/\[string\]\$result/.test(postTool), + 'post-tool-use.ps1 stringifies tool_result before classifying it, which is what hid the structured isError flag'); + + const cases = [ + // The two the review named. + { n: 'structured failure', result: { isError: true, output: 'command not found' }, want: 'error' }, + { n: 'structured success', result: { isError: false, output: 'all good' }, want: 'done' }, + // The flag survives a nested payload. + { n: 'structured failure with a nested payload', result: { isError: true, content: { text: 'boom' } }, want: 'error' }, + { n: 'structured success with a nested payload', result: { isError: false, content: { text: 'ok' } }, want: 'done' }, + // A bare string falls back to the textual heuristic. + { n: 'a string with an error prefix', result: 'Error: command not found', want: 'error' }, + { n: 'a plain string', result: 'all good', want: 'done' }, + // A result that is a JSON string still carries the flag. + { n: 'a JSON string carrying the failure flag', result: '{"isError":true,"output":"boom"}', want: 'error' }, + { n: 'a JSON string carrying the success flag', result: '{"isError":false,"output":"ok"}', want: 'done' }, + // Nothing at all is a failure: the tool produced no result. + { n: 'a null result', result: null, want: 'error' }, + { n: 'an empty string result', result: '', want: 'error' }, + ]; + + for (const c of cases) { + // A fresh %APPDATA% per case, so each run writes its own + // status.json and the assertion cannot read a previous case. + const appdata = makeIsolatedAppData('post-tool-use'); + const event = { + session_id: 'ci-fake-session', + transcript_path: 'C:\\fake\\transcript', + cwd: 'C:\\fake\\cwd', + hook_event_name: 'PostToolUse', + tool_name: 'Bash', + tool_input: { command: 'npm test' }, + tool_result: c.result, + }; + + const r = await runHookWithStdin(hookPath, JSON.stringify(event), { APPDATA: appdata }); + check(`post-tool-use ${c.n}: the hook exited cleanly`, + r.code === 0, + `exit ${r.code}: ${(r.err || r.out).trim().slice(0, 300)}`); + + const statusPath = join(appdata, 'mcode-island', 'status.json'); + if (!existsSync(statusPath)) { + bad(`post-tool-use ${c.n}`, `the hook wrote no status.json (exit ${r.code}): ${(r.err || r.out).trim().slice(0, 300)}`); + continue; + } + const status = readJsonFile(statusPath); + eq(`post-tool-use ${c.n}: the pushed state`, status.state, c.want); + eq(`post-tool-use ${c.n}: the pushed message`, status.message, c.want === 'error' ? 'Bash failed' : 'Bash ok'); + } +} + // --------------------------------------------------------------------------- // summary // --------------------------------------------------------------------------- From 807aaf5914fd652577ea8faeee42505528f2d8b8 Mon Sep 17 00:00:00 2001 From: antianqi Date: Thu, 22 Oct 2026 10:20:56 +0800 Subject: [PATCH 24/38] ci(mcode-island): the local Windows runner runs the behavioral suite Round-21 review (hetaoBackend, 2026-10-09) blocking issue #3: "The local Windows fallback runner still executes only four steps and omits test-substep-progress.mjs, so local reproduction differs from the GitHub workflow. Add the behavioral suite and fail on nonzero exit." The GitHub workflow has had step 5 since round-19 #5. The local runner did not, so the two paths were not the same test. That is worse than having no local runner at all: a runner that claims to mirror CI while running fewer steps than CI gives the person running it false confidence about what CI will say. The behavioral suite is the only suite that executes the PowerShell under test. It carries the secret canary (a typed Computer Use password must never reach status.json or the append-only log) and the two-writer interleaving regression (a status.json write must not lose a concurrent write). Neither of those was reachable from the local path. ## What changed scripts/test-windows-workflow-local.ps1 Step 5 added, 1:1 with .github/workflows/mcode-island-windows.yml step 5: resolve the suite against the repo root, run it with node, and fail on a non-zero exit code. The PowerShell report line is kept because it answers the question a failing run raises first: which interpreter did the suite use. The suite does its own resolution and falls back to PATH; it never assumes a user-specific absolute path. The repo root is now resolved from this script's own location instead of from the current directory. The workflow resolves it the same way (from $GITHUB_WORKSPACE). The old version only worked when invoked from the repo root -- from anywhere else it died at step 1 with a path error, which is a runner that silently reproduces nothing rather than a runner that reproduces CI. The header comment listed four steps; it now lists five and says why step 5 exists. ## Validation The local runner now executes all 5 steps and exits 0, with the behavioral suite reporting 116 pass, 0 fail inside it. The suite takes about 85 s on this machine, so the runner takes roughly two minutes. The GitHub job allows ten. ## Note on a cost this exposed, not fixed here The suite's ten PostToolUse cases run the real hook, and the hook pays for the caller tracking: Get-CallerFocusInfo asks WMI for the process chain, one query per level. Where the hook is spawned without a console -- a CI runner, a detached service -- GetConsoleWindow returns zero and the whole chain is walked, which measured 6.5 s per hook invocation on this host (Add-Type is 97 ms and a single CIM query is ~500 ms, so it is the chain, not the compile). Where a console IS inherited, the first branch returns immediately. That is a real latency cost for any deployment without a console, but it is not what the review asked about and it is a separate change with its own blast radius, so it is left alone here rather than folded into a commit about something else. The suite is inside the CI budget as measured, so the wiring stands either way. --- .../scripts/test-windows-workflow-local.ps1 | 68 +++++++++++++++++-- 1 file changed, 63 insertions(+), 5 deletions(-) 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 3674d695..4f9abc2a 100644 --- a/plugins/antianqi/mcode-island/scripts/test-windows-workflow-local.ps1 +++ b/plugins/antianqi/mcode-island/scripts/test-windows-workflow-local.ps1 @@ -8,13 +8,24 @@ # - You want to develop / debug the contract surfaces without # waiting for the CI queue. # -# Steps verified (all 4 are PR #21 round-5 requirements): -# 1. Parse all .ps1 files (round-5 #1) - all 27 parse OK +# Steps verified (mirrors .github/workflows/mcode-island-windows.yml 1:1): +# 1. Parse all .ps1 files (round-5 #1) - all 31 parse OK # 2. Token set / show / clear roundtrip - 4 / 4 checks pass # 3. Hook stdin / stdout writes status.json - state=working, source=agent # 4. Mocked usage-API roundtrip - auth + path + body shape +# 5. Behavioral sub-step + redaction suite - the only suite that +# executes the PowerShell +# under test # -# Usage (from the repo root, with PowerShell 7+): +# Step 5 was missing here while the workflow had it, and that is exactly +# what the round-21 review called out: a local runner with fewer steps +# than CI is a runner that does not reproduce CI. The behavioral suite +# carries the secret canary and the two-writer interleaving regression, +# so a redaction or atomicity regression could pass locally and fail in +# CI -- or the other way round, which is worse: a suite that only ever +# runs on the runner stops being evidence for the person running it. +# +# Usage (PowerShell 7+; works from any directory): # pwsh -File plugins/antianqi/mcode-island/scripts/test-windows-workflow-local.ps1 # # Exit code: 0 on full pass, 1 on any failure. Each step prints a @@ -33,7 +44,13 @@ $OutputEncoding = [System.Text.Encoding]::UTF8 try { chcp 65001 | Out-Null } catch {} $ErrorActionPreference = 'Stop' -$repoRoot = (Get-Location).Path +# The repo root, resolved from this script's own location rather than +# from the current directory. The workflow resolves it the same way +# (from $GITHUB_WORKSPACE). A runner that only works when invoked from +# one specific directory is a runner that silently reproduces nothing +# from anywhere else -- it dies at step 1 with a path error instead of +# doing the thing. +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..\..\..')).Path # --- Shared fixtures ---------------------------------------------------- @@ -273,5 +290,46 @@ Write-Host "OK Step 4c (no token): Get-5hUsage returned null" Write-Host "OK Step 4: Get-5hUsage via dot-source + matching fixture + token-source precedence: 3/3 OK" Write-Host "" -Write-Host "=== All 4 steps OK ===" + +# --- Step 5: behavioral sub-step + redaction suite ---------------------- + +Write-Host "--- Step 5: behavioral sub-step + redaction suite ---" + +# 1:1 with .github/workflows/mcode-island-windows.yml step 5. The suite +# resolves its own PowerShell: $env:PS_BIN if set, otherwise an existing +# path, otherwise pwsh / powershell.exe / powershell on PATH. It never +# assumes a user-specific absolute path. +[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, not the current +# directory, so the runner behaves the same whatever it is invoked from. +$suite = Join-Path $repoRoot 'plugins/antianqi/mcode-island/scripts/test-substep-progress.mjs' +if (-not (Test-Path $suite)) { + throw "Step 5: $suite not found (cwd: $((Get-Location).Path))" +} +Write-Host "Suite: $((Resolve-Path $suite).Path)" + +node $suite +if ($LASTEXITCODE -ne 0) { + throw "Step 5: test-substep-progress.mjs failed with exit code $LASTEXITCODE" +} + +Write-Host "OK Step 5: behavioral sub-step + redaction suite" +Write-Host "" +Write-Host "=== All 5 steps OK ===" exit 0 From d4ab8e3761b4338c708c20e0965c2f67d408e56c Mon Sep 17 00:00:00 2001 From: antianqi Date: Thu, 22 Oct 2026 10:26:41 +0800 Subject: [PATCH 25/38] ci(mcode-island): gate the hook subprocess section to Windows The ubuntu validate job went red on the previous push. `node --test` discovers this file by its name (`test-*.mjs`), so the repository's ubuntu job runs the behavioral suite as well as the Windows one. The new PostToolUse section failed there, and it failed for a real reason rather than a flaky one: the hook calls `chcp 65001` and `Add-Type` with Win32 P/Invoke signatures, so it cannot run anywhere else. There is no console to change the code page of and no user32.dll to bind. The section is now gated to Windows and skipped LOUDLY -- the skip is counted and printed in the summary, because a skip that is invisible reads as a pass. The Windows job runs the whole suite with nothing skipped. The rest of the suite runs on both platforms, and that was already true before this change: the status.json protocol is plain PowerShell and the suite supplies an isolated %APPDATA%, so the store resolves to a scratch directory on either host. The ubuntu log confirms it -- sections 3, 5, 7 and 8 all passed there, and the only failures were the ten hook cases. Validation windows : 116 pass, 0 fail, 0 skipped the ubuntu job is the evidence for the other side --- .../scripts/test-substep-progress.mjs | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/plugins/antianqi/mcode-island/scripts/test-substep-progress.mjs b/plugins/antianqi/mcode-island/scripts/test-substep-progress.mjs index a6abe2ab..082c43a9 100644 --- a/plugins/antianqi/mcode-island/scripts/test-substep-progress.mjs +++ b/plugins/antianqi/mcode-island/scripts/test-substep-progress.mjs @@ -22,6 +22,10 @@ const PLUGIN_ROOT = dirname(HERE); let pass = 0; let fail = 0; +// A skip that is not counted is a skip that reads as a pass. The suite +// skips a Windows-only section on other platforms, and that fact has +// to be visible in the summary rather than only in the scrollback. +let skipped = 0; const failures = []; function ok(name) { pass++; console.log(`[ok ] ${name}`); } @@ -992,6 +996,18 @@ try { // event on stdin, and read back what it wrote to status.json. A // function-body test would not prove the hook wires the classification // to the state it pushes. +// +// WINDOWS ONLY. The hook calls `chcp 65001` and `Add-Type` with Win32 +// P/Invoke signatures, so it cannot run anywhere else -- there is no +// console to change the code page of and no user32.dll to bind. The +// repository's `node --test` picks this file up on the ubuntu job as +// well as the Windows one, so this section is gated rather than +// failing there. It is skipped LOUDLY: a skip that is invisible reads +// as a pass. +// +// The rest of the suite runs on both platforms. The status.json +// protocol is plain PowerShell, and the suite supplies an isolated +// %APPDATA% so the store resolves to a scratch directory. /** * Run a hook script as a real child process with a JSON event on stdin. @@ -1014,7 +1030,10 @@ function runHookWithStdin(hookPath, eventJson, extraEnv) { }); } -if (PS_BIN) { +if (process.platform !== 'win32') { + console.log('[skip] section 9: the PostToolUse hook needs Windows (chcp, Add-Type with Win32 P/Invoke)'); + skipped++; +} else if (PS_BIN) { const hookPath = join(PLUGIN_ROOT, 'io.minimax.mcode', 'hooks', 'scripts', 'post-tool-use.ps1'); const postTool = readFileSync(hookPath, 'utf8'); @@ -1080,7 +1099,7 @@ if (PS_BIN) { try { rmSync(TMP, { recursive: true, force: true }); } catch { /* best effort */ } console.log('-'.repeat(60)); -console.log(`${pass} pass, ${fail} fail`); +console.log(`${pass} pass, ${fail} fail${skipped ? `, ${skipped} skipped (platform)` : ''}`); if (fail > 0) { console.log('\nfailures:'); for (const f of failures) console.log(` - ${f}`); From 3bae38593efcdb0c1d4fa24bf1ad2ee35e5deec2 Mon Sep 17 00:00:00 2001 From: antianqi Date: Thu, 22 Oct 2026 22:52:54 +0800 Subject: [PATCH 26/38] fix(mcode-island): dragging the pill no longer shows a white box Dragging the pill showed a white rectangle the size of the window (320x70) in place of the pill. ## Why The drag used WPF's Window.DragMove(), which sends WM_SYSCOMMAND / SC_MOVE and enters the Win32 modal move loop. Inside that loop the WPF render thread is not composited: DWM stops recompositing the layered window -- the per-pixel alpha surface that AllowsTransparency="True" gives the pill its rounded, shadowed shape. What is left on screen is that surface before it was ever painted: a white rectangle with the window's exact bounds. The DropShadowEffect makes it more obvious, because the effect is software-rendered and that frame is not drawn at all. ## The fix The drag is implemented directly: the normal message pump updates Window.Left / Window.Top, so WPF composites every frame as usual and the artifact cannot appear. The cost is the system-level move feel of DragMove() (edge snapping), which this pill does not use. Two details that are easy to get wrong: - GetPosition($window) is relative to the window, and the window is following the mouse, so the relative delta is always ~0. The screen coordinates (PointToScreen) are differenced instead, and converted back to the device-independent units Left/Top use. - The 5px threshold gates the START of the drag, not the movement. A guard that returns once didDrag is set makes the pill follow the cursor for exactly one event and then stop. The mouse is captured on button-down so the drag does not stick when the pointer leaves the 320x70 window, and released on button-up. ## The BOM is not cosmetic This file contains non-ASCII, and it had no UTF-8 BOM. Windows PowerShell 5.1 -- the interpreter the widget actually runs under -- decodes a BOM-less script with the system ANSI codepage, so the C# here-string stops being a here-string and the file does not parse at all. Measured on this host, both ways, on the exact committed bytes: no BOM -> PS 5.1: FAILS: Unexpected token '}' in expression or statement. with BOM -> PS 5.1: parses OK This is the round-20 #4 contract ("every non-ASCII .ps1 carries a UTF-8 BOM"), and this file was the one that had slipped through it. ## Test evidence - Windows PowerShell 5.1 parses the committed file: OK - the fix was verified by hand on the target OS: dragging the pill shows no white box and the pill follows the cursor A note on the automated attempt, because it is a trap worth recording: injecting a synthetic drag with SetCursorPos + mouse_event does NOT reproduce a drag. SetCursorPos moves the pointer without generating mouse-move messages, so the widget never sees the movement and the harness measures nothing while looking like it measured something. The evidence above is the manual interaction plus the parse check; the synthetic harness was dropped rather than kept as false evidence. ## Design compliance no credentials : none added no network : no network calls added no telemetry : none added; widget.log unchanged no third-party svcs : no new dependencies; pure PowerShell + WPF cross-platform : no host-absolute paths introduced encoding : the file keeps a UTF-8 BOM, as the round-20 #4 contract requires for every non-ASCII .ps1 behaviour change : the pill is no longer moved by the OS move loop, so edge snapping is gone. Nothing else about the drag changes: the same 5px threshold separates a click from a drag, and a click still toggles the CLI window. --- .../antianqi/mcode-island/mcode-island.ps1 | 79 +++++++++++++++++-- 1 file changed, 72 insertions(+), 7 deletions(-) diff --git a/plugins/antianqi/mcode-island/mcode-island.ps1 b/plugins/antianqi/mcode-island/mcode-island.ps1 index 3cfeafc3..8df29042 100644 --- a/plugins/antianqi/mcode-island/mcode-island.ps1 +++ b/plugins/antianqi/mcode-island/mcode-island.ps1 @@ -1,4 +1,4 @@ -# mcode 灵动岛 v1 - WPF + PowerShell +# mcode 灵动岛 v1 - WPF + PowerShell # 用法:右键 → 用 PowerShell 运行;或通过 start-island.ps1 启动 $ErrorActionPreference = 'Stop' @@ -810,26 +810,91 @@ $window.Add_MouseRightButtonDown({ }) # 点击 vs 拖动 +# +# 为什么不用 DragMove()。 +# +# DragMove() 会 SendMessage(WM_SYSCOMMAND, SC_MOVE) 进入 Win32 的模态移动 +# 循环。那个循环里 WPF 的渲染线程不参与合成:DWM 不再把分层窗口 +# (AllowsTransparency=True 的 per-pixel alpha 表面)重新合成出来,屏幕上 +# 剩下的就是那块没被绘制过的表面 —— 一块白框,尺寸正好是窗口的 320x60。 +# 窗口上的 DropShadowEffect 让它更明显:效果本身是软件渲染的,模态循环 +# 期间那一帧根本没画出来。 +# +# 所以这里自己实现拖动:正常消息泵里改 Left/Top,WPF 每帧照常合成, +# 白框不会出现。代价是失去 DragMove() 的系统级移动手感(边缘吸附等), +# 这个 pill 用不上那些。 +# +# 另一个坑:MouseMove 里的 GetPosition($window) 是相对窗口的,而窗口正在 +# 跟着鼠标走,所以相对位移恒为 0,直接用它算 delta 窗口永远不动。必须拿 +# 屏幕坐标(PointToScreen)做差,再按 DPI 缩放换回 Left/Top 用的 DIP。 $script:dragStart = $null $script:didDrag = $false +$script:dragOriginLeft = 0.0 +$script:dragOriginTop = 0.0 +$script:dragAnchorX = 0.0 +$script:dragAnchorY = 0.0 $script:pill.RenderTransform = New-Object System.Windows.Media.ScaleTransform(1, 1) $script:pill.RenderTransformOrigin = New-Object System.Windows.Point(0.5, 0.5) + +# 屏幕像素 -> DIP 的换算系数。PointToScreen 给的是设备像素,Window.Left/Top +# 用的是设备无关单位,缩放不是 100% 时两者不等。 +function Get-DragScale { + $sx = 1.0 + $sy = 1.0 + try { + $src = [System.Windows.PresentationSource]::FromVisual($window) + if ($src -and $src.CompositionTarget) { + $m = $src.CompositionTarget.TransformToDevice + if ($m.M11 -gt 0) { $sx = [double]$m.M11 } + if ($m.M22 -gt 0) { $sy = [double]$m.M22 } + } + } catch {} + return @{ X = $sx; Y = $sy } +} + $window.Add_MouseLeftButtonDown({ $script:dragStart = $_.GetPosition($window) $script:didDrag = $false + $script:dragOriginLeft = $window.Left + $script:dragOriginTop = $window.Top + try { + $anchor = $window.PointToScreen($script:dragStart) + $script:dragAnchorX = $anchor.X + $script:dragAnchorY = $anchor.Y + } catch { + $script:dragAnchorX = $script:dragStart.X + $script:dragAnchorY = $script:dragStart.Y + } + # 抓住鼠标,否则指针一移出 320x60 的窗口 MouseMove 就不再来,拖动会"粘住"。 + try { $window.CaptureMouse() } catch {} }) $window.Add_MouseMove({ - if ($script:dragStart -and -not $script:didDrag) { - $cur = $_.GetPosition($window) + if ($null -eq $script:dragStart) { return } + # The position is read EVERY event, not only before the threshold is + # crossed: the window is moving, so a position read during an earlier + # event is stale relative to where the window is now. + $cur = $_.GetPosition($window) + # The threshold gates the START of the drag, not the movement. The + # previous guard returned as soon as didDrag was set, so the pill + # followed the cursor for exactly one event and then stopped -- + # measured at 14% of the cursor movement. + if (-not $script:didDrag) { $dx = [Math]::Abs($cur.X - $script:dragStart.X) $dy = [Math]::Abs($cur.Y - $script:dragStart.Y) - if ($dx + $dy -gt 5) { - $script:didDrag = $true - try { $window.DragMove() } catch {} - } + if ($dx + $dy -le 5) { return } + $script:didDrag = $true + } + try { + $now = $window.PointToScreen($cur) + $scale = Get-DragScale + $window.Left = $script:dragOriginLeft + (($now.X - $script:dragAnchorX) / $scale.X) + $window.Top = $script:dragOriginTop + (($now.Y - $script:dragAnchorY) / $scale.Y) + } catch { + Dbg "DRAG FAIL: $($_.Exception.Message)" } }) $window.Add_MouseLeftButtonUp({ + try { $window.ReleaseMouseCapture() } catch {} try { if ($script:dragStart -and -not $script:didDrag) { Dbg 'CLICK detected' From 308fd7b028ac9eafb3114b9abbdaad3e8e3ab904 Mon Sep 17 00:00:00 2001 From: antianqi Date: Thu, 22 Oct 2026 22:56:41 +0800 Subject: [PATCH 27/38] fix(mcode-island): the widget is ready when its log says so, and starting it also starts the detector Two problems in start-island.ps1, both visible in daily use. 1. Readiness was decided by `Process.MainWindowHandle != 0`. That Win32 heuristic does not work for this window: it is borderless, always-on-top and transparent, so the handle stays 0 forever. Every start therefore reported "starting" and waited out the full 8 seconds, even though the widget was up in about one. The report itself was harmless, but it looks exactly like "it is stuck", which sends whoever is debugging it down the wrong path. The widget now writes "starting Application.Run" before it enters the message loop, and start-island.ps1 waits for exactly that line. Only lines appended after this launch count -- widget.log is append-only, so a stale marker from a previous run must not be mistaken for this one's readiness (otherwise a stop followed by a start reads the old marker). 2. The detector was not started by start-island.ps1. On 2026-10-06 14:37:42 the detector died on an unhandled terminating error and left a single "detector stopped" line behind. The widget stayed alive, so status.json froze and the pill looked dead for about 23 hours. The only recovery a user can think of is "restart the island", and if that does not bring the detector back, restarting achieves nothing. start-island.ps1 now starts the detector too. start-detect-island.ps1 is idempotent (it checks detect.pid and verifies that the PID still points at mcode-status-detect.ps1), so an already running detector is not started twice. A failure to start the detector is a warning only: the widget is up either way. --- .../antianqi/mcode-island/start-island.ps1 | 51 ++++++++++++++++--- 1 file changed, 45 insertions(+), 6 deletions(-) diff --git a/plugins/antianqi/mcode-island/start-island.ps1 b/plugins/antianqi/mcode-island/start-island.ps1 index 3704a89a..51839780 100644 --- a/plugins/antianqi/mcode-island/start-island.ps1 +++ b/plugins/antianqi/mcode-island/start-island.ps1 @@ -48,19 +48,58 @@ $proc = [System.Diagnostics.Process]::Start($psi) # 写 PID(先写,后面 status / stop 都靠这个) Set-Content -Path $pidFile -Value $proc.Id -Encoding ASCII -# 等待 widget 起来(最多 8 秒),用 .ps1 已加载 WPF 窗口作为 readiness 信号 -$widgetWindowClass = 'HwndWrapper[DefaultDomain*' # WPF 窗口类名前缀 +# 等待 widget 就绪(最多 8 秒)。 +# 这里原来用 Process.MainWindowHandle != 0 判定就绪,但这个无边框 / always-on-top +# / 透明的 WPF 窗口对 Win32 的 MainWindow 启发式不友好,句柄常年为 0 —— 于是每次 +# 启动都误报 "等待中",哪怕 widget 1 秒后就绪。误报本身无害,但它跟"卡住了" +# 长得很像,排查时很容易把人带偏。widget 会在进入消息循环前先写一行 +# "starting Application.Run",那才是可靠信号。 +$widgetLog = Join-Path $configDir 'widget.log' +$widgetReady = 'starting Application.Run' +# 只认本次启动之后新增的日志行:widget.log 是 append-only 的,重启前的旧行 +# 不能用来判定本次是否就绪,否则刚 stop 就 start 时会读到上一轮的标记。 +$logLenBefore = 0 +if (Test-Path -LiteralPath $widgetLog) { + try { $logLenBefore = (Get-Item -LiteralPath $widgetLog).Length } catch { $logLenBefore = 0 } +} + $ok = $false for ($i = 0; $i -lt 16; $i++) { Start-Sleep -Milliseconds 500 try { - $p = Get-Process -Id $proc.Id -ErrorAction Stop - if ($p.HasExited) { break } - # WPF widget 启动后会创建主窗口;MainWindowHandle 非 0 即就绪 - if ($p.MainWindowHandle -ne 0) { $ok = $true; break } + if ((Get-Process -Id $proc.Id -ErrorAction Stop).HasExited) { break } + } catch { break } + + if (-not (Test-Path -LiteralPath $widgetLog)) { continue } + try { + $fi = Get-Item -LiteralPath $widgetLog + if ($fi.Length -le $logLenBefore) { continue } + $fs = [System.IO.File]::Open($fi.FullName, 'Open', 'Read', 'ReadWrite') + try { + [void]$fs.Seek($logLenBefore, 'Begin') + $added = (New-Object System.IO.StreamReader($fs, [System.Text.Encoding]::UTF8)).ReadToEnd() + } finally { $fs.Dispose() } + if ($added.IndexOf($widgetReady, [System.StringComparison]::Ordinal) -ge 0) { $ok = $true; break } } catch {} } +# 顺带拉起 detector。start-detect-island.ps1 自身是幂等的(先看 detect.pid, +# 再核对那个 PID 的命令行是否仍指向 mcode-status-detect.ps1),所以 detector +# 已经在跑时这里不会重复起。 +# 为什么必须放在这里:2026-10-06 14:37:42 detector 因未捕获的终止性错误死掉, +# 只留了一行 "detector stopped",widget 却活着,于是 status.json 冻结、pill 看着 +# 像卡死,整整 ~23 小时。用户能想到的恢复动作就是"重启灵动岛",如果这条路带不起 +# detector,重启等于没做。detector 挂了不影响 widget,所以失败只警告不抛出。 +$detectStarter = Join-Path $PSScriptRoot 'start-detect-island.ps1' +if (Test-Path $detectStarter) { + try { + $detOut = & $detectStarter 2>&1 + Write-Output ("detector: " + (($detOut | Out-String).Trim())) + } catch { + Write-Output ("detector start failed (widget is up anyway): " + $_.Exception.Message) + } +} + if ($ok) { Write-Output ("mcode-island 已启动 (PID " + $proc.Id + ",已就绪)") } else { From bee4835b345ae7644a3120287228792339e6751d Mon Sep 17 00:00:00 2001 From: antianqi Date: Thu, 22 Oct 2026 09:59:57 +0800 Subject: [PATCH 28/38] fix(mcode-island): three defects the Marketplace review rejected The MiniMax Marketplace review of the mcode-island submission package rejected the build for five reasons. Three of them are real defects in the hosted plugin rather than packaging issues, and they are fixed here. 1. autostart.ps1 ran `New-Item -Path $runKey -Force` on the HKCU\Software\Microsoft\Windows\CurrentVersion\Run key. With -Force that recreates the key, so every other autostart entry the user registered (Everything, OneDrive, proxy clients, ...) was wiped. The key is now created only when it is missing, the two mcode-island values are written with New-ItemProperty, and the script verifies afterwards that every unrelated entry in that key is still there. 2. The state detector could not run on the desktop app at all. It resolved the MiniMax Code *CLI* install root and exited with "Cannot find mcode install root" when that directory was absent, which is the normal case for a desktop-only install. It also used the CLI-only .mcode-active directory to decide whether the app was still running, so a desktop user got a permanent "mcode has exited" error and the session log was never read. The root is now resolved from the desktop data directory ($env:MINIMAX_DATA_DIR, or /.minimax), the process check is gone, and the "60s without a new event -> idle" fallback closes the loop. 3. MSG_IDLE_FMT encoded 0xE9,0x9C,0xA8 (U+9728) instead of 0xE9,0xBB,0x98 (U+9ED8), so the pill rendered the wrong character in the idle message. On top of that the 5h-usage feature is now gated on the presence of scripts/lib/Get-5hUsage.ps1: the token resolution and the refresh logic moved into that lib, and the detector only enables the feature when the lib is there. The hosted plugin keeps the 5h badge; a build that does not ship the lib now reads no credential and makes no network request at all. Validation * the autostart registry test against the hosted plugin: Enable kept all 7 unrelated Run entries, wrote exactly our two values, Disable removed only ours and left the unrelated entries alone * Windows PowerShell 5.1: mcode-status-detect.ps1 -Once reads the desktop session store (~/.minimax/v2/sessions) and infers the state (state=working, usage5h=58, source=detector) Test evidence * the registry test plants a sentinel value next to the plugin's own entries, runs Enable and Disable, and asserts the sentinel survives both times; the user's own values are snapshotted and restored * the detector was run under Windows PowerShell 5.1 against the desktop data directory, before and after the refactor Design compliance * the 5h usage feature stays optional: the hosted plugin still shows the badge, and a build without the lib can read no token * the detector no longer depends on the CLI, on the .mcode-active directory or on a running node process --- plugins/antianqi/mcode-island/autostart.ps1 | 30 ++- .../mcode-island/mcode-status-detect.ps1 | 233 ++++++------------ .../mcode-island/scripts/lib/Get-5hUsage.ps1 | 52 +++- 3 files changed, 160 insertions(+), 155 deletions(-) diff --git a/plugins/antianqi/mcode-island/autostart.ps1 b/plugins/antianqi/mcode-island/autostart.ps1 index 17b1a9af..360cd276 100644 --- a/plugins/antianqi/mcode-island/autostart.ps1 +++ b/plugins/antianqi/mcode-island/autostart.ps1 @@ -32,13 +32,39 @@ function Build-Command([string]$Launcher) { switch ($Action) { 'Enable' { - New-Item -Path $runKey -Force | Out-Null + # 只在 Run 键不存在时创建它。 + # + # 之前这里是无条件 New-Item -Path $runKey -Force:对一个已经存在的 + # HKCU\...\Run 键,-Force 会重建这个键,于是把用户注册在它下面的 + # 其它开机自启项(Everything / OneDrive / 各种代理客户端 …)一起清掉。 + # 现在只在键缺失时创建,并且用 New-ItemProperty 只写自己的那一个值, + # 绝不碰同键下的其它值。 + if (-not (Test-Path $runKey)) { + New-Item -Path $runKey -Force | Out-Null + } + + # 记录同键下的其它值,写完再核对一遍,确保一个都没少。 + $othersBefore = @((Get-Item -Path $runKey -ErrorAction SilentlyContinue).GetValueNames() | + Where-Object { $entries.Keys -notcontains $_ }) + foreach ($name in $entries.Keys) { $cmd = Build-Command $entries[$name] - Set-ItemProperty -Path $runKey -Name $name -Value $cmd + # -Force 只影响这一个具名值:不存在则建,存在则覆盖。 + New-ItemProperty -Path $runKey -Name $name -Value $cmd -PropertyType String -Force | Out-Null Write-Output "ENABLED: $runKey\$name" Write-Output " Value: $cmd" } + + $othersAfter = @((Get-Item -Path $runKey -ErrorAction SilentlyContinue).GetValueNames() | + Where-Object { $entries.Keys -notcontains $_ }) + $lost = @($othersBefore | Where-Object { $othersAfter -notcontains $_ }) + if ($lost.Count -gt 0) { + Write-Output "ERROR: unrelated Run entries disappeared: $($lost -join ', ')" + exit 1 + } + if ($othersAfter.Count -gt 0) { + Write-Output " (kept $($othersAfter.Count) unrelated Run entr(y|ies))" + } } 'Disable' { $any = $false diff --git a/plugins/antianqi/mcode-island/mcode-status-detect.ps1 b/plugins/antianqi/mcode-island/mcode-status-detect.ps1 index e5149cb8..5a24ba0b 100644 --- a/plugins/antianqi/mcode-island/mcode-status-detect.ps1 +++ b/plugins/antianqi/mcode-island/mcode-status-detect.ps1 @@ -3,14 +3,13 @@ # 让 widget 不依赖 agent 主动调 notify-island.ps1 也能跟着动 # # 路径解析(顺序): -# 1. 从 mcode node 进程 cmdline 提取 /.minimax-code -# 2. 探测 $env:USERPROFILE/.minimax-code -# 3. 探测 $env:USERPROFILE/AppData/Roaming/minimax-code/.minimax-code -# 4. 探测 /.minimax-code +# 1. 用户显式 -Root 覆盖 +# 2. 桌面端数据目录 $env:MINIMAX_DATA_DIR(没有则用 /.minimax) +# 3. # # session log(顺序): -# 1. /.minimax/v2/sessions//*/ledger.jsonl(首选,mcode v2 事件流) -# 2. /.minimax/v2/sessions//*/messages.jsonl(fallback) +# 1. /v2/sessions//*/ledger.jsonl(首选,mcode v2 事件流) +# 2. /v2/sessions//*/messages.jsonl(fallback) # # 状态映射: # role=user → idle (等用户) @@ -33,7 +32,7 @@ param( [switch]$Once, - [string]$Root = '' # override: /.minimax-code and /.minimax/v2/... + [string]$Root = '' # override: the directory that holds .minimax/v2/sessions ) $ErrorActionPreference = 'Stop' @@ -80,8 +79,7 @@ $S_DETECTOR = _s (0x64,0x65,0x74,0x65,0x63,0x74,0x6F,0x72) $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_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 +$MSG_IDLE_FMT = _s (0xE5,0xB7,0xB2,0xE9,0x9D,0x99,0xE9,0xBB,0x98,0x20,0x7B,0x30,0x7D,0x73) # 已静默 {0}s $DOTS = _s (0x2E,0x2E,0x2E) # ... $FMT_O = _s (0x6F) # 'o' (ISO 8601 timestamp) $FMT_O_FULL = _s (0x4F) # 'O' (ISO 8601 full) @@ -138,12 +136,6 @@ $TOOL_FAMILIES = @{ # 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 -$ACTIVE_DIR = _s (0x2E,0x6D,0x63,0x6F,0x64,0x65,0x2D,0x61,0x63,0x74,0x69,0x76,0x65) # .mcode-active -# .minimax-code directory name (parent of .mcode-active) -$MINIMAX_CODE = _s (0x2E,0x6D,0x69,0x6E,0x69,0x6D,0x61,0x78,0x2D,0x63,0x6F,0x64,0x65) # .minimax-code # v2 sessions relative to user profile $V2_SESSIONS_REL = _s (0x2E,0x6D,0x69,0x6E,0x69,0x6D,0x61,0x78,0x5C,0x76,0x32,0x5C,0x73,0x65,0x73,0x73,0x69,0x6F,0x6E,0x73) # .minimax\v2\sessions @@ -156,15 +148,18 @@ $pidFile = Join-Path $configDir 'detect.pid' $cfgFile = Join-Path $configDir 'config.json' # 5h 用量 API:每 60s 调一次 minimax /v1/coding_plan/remains,写进 status.json 的 usage5h 字段 -# token 来源:env MINIMAX_OAUTH_TOKEN 优先;fallback 到 config.json 的 planApiToken # -# The actual API call (Get-5hUsage) and the URL/host constants live -# in scripts/lib/Get-5hUsage.ps1. They are extracted into a lib so -# the windows-latest CI job (and the local runner) can exercise the -# 5h usage path against a mocked listener without requiring a real -# 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" +# 这是一个**可选**能力。真正干活的代码(URL、请求头、token 解析、刷新逻辑) +# 全部在 scripts/lib/Get-5hUsage.ps1 里,detector 这里只判断"这个 lib 在不在"。 +# +# lib 不在时(MiniMax Marketplace 的上架包就不携带它),整条 5h 链路直接关闭: +# 不读任何 token、不发任何网络请求、status.json 里也没有 usage5h 字段。 +$script:plan5hAvailable = $false +if (Test-Path "$PSScriptRoot/scripts/lib/Get-5hUsage.ps1") { + . "$PSScriptRoot/scripts/lib/Get-5hUsage.ps1" + $script:plan5hAvailable = $true + Resolve-PlanToken +} # 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 @@ -179,18 +174,6 @@ $cfgFile = Join-Path $configDir 'config.json' # the lock, the single snapshot read and the atomic replace, so both # sides follow one protocol. . "$PSScriptRoot/scripts/lib/Status-Store.ps1" -$PLAN_API_TTL = [TimeSpan]::FromSeconds(60) -$script:plan5hToken = $null -if ($env:MINIMAX_OAUTH_TOKEN) { $script:plan5hToken = $env:MINIMAX_OAUTH_TOKEN } -elseif ($env:MINIMAX_API_KEY) { $script:plan5hToken = $env:MINIMAX_API_KEY } -elseif (Test-Path $cfgFile) { - try { - $cfg = [System.IO.File]::ReadAllText($cfgFile) | ConvertFrom-Json - if ($cfg.PSObject.Properties['planApiToken'] -and $cfg.planApiToken) { - $script:plan5hToken = [string]$cfg.planApiToken - } - } catch {} -} $script:plan5hLastCallAt = [DateTime]::MinValue $script:plan5hRemainingPct = $null # 0..100,剩余百分比(不再是已用!) $script:plan5hResetMs = $null # 距下次刷新的毫秒数 @@ -200,84 +183,82 @@ $script:plan5hTodoData = $null # @{ percent; currentTodo; completed; total $script:plan5hTodoCacheMtime = [DateTime]::MinValue $script:plan5hLastWrittenTodoPct = $null # 上次写到 status.json 的 todoProgress(用来检测变化) -# 解析 mcode 安装根目录(/.minimax-code) +# 解析 MiniMax Code 的数据根目录 +# +# 投放目标是桌面端(MiniMax Code app),所以优先按桌面端的数据目录找: +# 会话流固定在 /v2/sessions,根目录取 的父目录。 +# MINIMAX_DATA_DIR 是官方支持的数据目录覆盖环境变量。 +# +# 之前这里只认 MiniMax Code CLI 的安装目录,而且是从 CLI 的 node 进程 +# cmdline 里反查出来的。桌面端用户没有装 CLI,于是这个函数返回 null, +# detector 直接 exit 2 —— 状态检测在桌面端根本跑不起来。 function Find-McodeRoot { # 1) 用户显式 -Root 覆盖 if ($script:optRoot -and (Test-Path $script:optRoot)) { return $script:optRoot } - # 2) 从 mcode node 进程 cmdline 提取 - $procs = Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | - Where-Object { $_.CommandLine -and $_.CommandLine.Contains($CLI_FRAGMENT) } - foreach ($p in $procs) { - try { - $m = [regex]::Match($p.CommandLine, '([A-Za-z]:\\[^"'']*?)\.minimax-code\\') - if ($m.Success) { - $root = $m.Groups[1].Value + $MINIMAX_CODE - if (Test-Path $root) { return $root } - } - } catch {} - } - - # 3) 探测 $env:USERPROFILE/.minimax-code - if ($env:USERPROFILE) { - $candidate = Join-Path $env:USERPROFILE $MINIMAX_CODE - if (Test-Path $candidate) { return $candidate } - } - - # 4) 探测 $env:USERPROFILE/AppData/Roaming/minimax-code/.minimax-code - if ($env:APPDATA) { - $candidate = Join-Path (Join-Path $env:APPDATA 'minimax-code') $MINIMAX_CODE - if (Test-Path $candidate) { return $candidate } + # 2) 桌面端数据目录 + foreach ($dataDir in Get-DataDirs) { + if (Test-Path (Join-Path $dataDir 'v2\sessions')) { + $parent = Split-Path -Parent $dataDir + if ($parent) { return $parent } + } } - # 5) 探测 /.minimax-code - try { - $cwd = (Get-Location).Path - $candidate = Join-Path $cwd $MINIMAX_CODE - if (Test-Path $candidate) { return $candidate } - } catch {} + # 3) 兜底:用户主目录 + if ($env:USERPROFILE) { return $env:USERPROFILE } return $null } -# sessions 根目录(优先 mcode 安装目录上一级,再 fallback userprofile) +# 桌面端的数据目录候选(按优先级) +function Get-DataDirs { + $dirs = @() + if ($env:MINIMAX_DATA_DIR) { $dirs += $env:MINIMAX_DATA_DIR } + if ($env:USERPROFILE) { $dirs += (Join-Path $env:USERPROFILE '.minimax') } + return $dirs +} + +# sessions 根目录(桌面端优先) function Get-SessionsRoot($mcodeRoot) { - if ($mcodeRoot) { - # .minimax-code 和 .minimax 平级,都在 下 - $parent = Split-Path -Parent $mcodeRoot - $candidate = Join-Path $parent (Join-Path '.minimax' (Join-Path 'v2' 'sessions')) + # 1) 桌面端:/v2/sessions + foreach ($dataDir in Get-DataDirs) { + $candidate = Join-Path $dataDir 'v2\sessions' if (Test-Path $candidate) { return $candidate } } + + # 2) /.minimax/v2/sessions if ($env:USERPROFILE) { $candidate = Join-Path $env:USERPROFILE $V2_SESSIONS_REL if (Test-Path $candidate) { return $candidate } } + + # 3) 兜底:根目录的上一级里找 .minimax/v2/sessions + if ($mcodeRoot) { + $parent = Split-Path -Parent $mcodeRoot + $candidate = Join-Path $parent (Join-Path '.minimax' (Join-Path 'v2' 'sessions')) + if (Test-Path $candidate) { return $candidate } + } + return $null } $script:optRoot = $Root $mcodeRoot = Find-McodeRoot -if (-not $mcodeRoot) { - Write-Error "Cannot find mcode install root (.minimax-code). Pass -Root or ensure mcode is running." - exit 2 -} -$mcodeActiveDir = Join-Path $mcodeRoot $ACTIVE_DIR $sessionsRoot = Get-SessionsRoot $mcodeRoot -if (-not (Test-Path $mcodeActiveDir)) { - Write-Warning ".mcode-active not found at $mcodeActiveDir — mcode not running or different layout" +if (-not $sessionsRoot) { + Write-Error "Cannot find the MiniMax Code session store (~/.minimax/v2/sessions). Set MINIMAX_DATA_DIR or pass -Root." + exit 2 } # 全局状态 $script:lastDetectedState = '' $script:lastMsgsSnapshot = @{ mtime = ''; lastInferred = $null } -# 5s 缓存:mcode 进程 PID + 最新 session log 路径 -# 避免每秒调 Get-McodePid / Get-LatestSessionFile(每次都涉及 Get-ChildItem +# 5s 缓存:最新 session log 路径 +# 避免每秒调 Get-LatestSessionFile(每次都涉及 Get-ChildItem # 之类的 cmdlet,PS 5.1 hidden window 下会累积 Runspace 线程,9 小时后 # 池子被占满 → poll 不再前进 → widget 卡死)。 $CACHE_TTL = [TimeSpan]::FromSeconds(5) -$script:lastMcodePid = $null -$script:lastMcodePidAt = [DateTime]::MinValue $script:lastLatestFile = $null $script:lastLatestFileAt = [DateTime]::MinValue @@ -315,34 +296,6 @@ function Log-Line($msg) { if (-not $Once) { Write-Output $line } } -function Get-McodePid { - if (-not (Test-Path $mcodeActiveDir)) { return $null } - # Use [System.IO.Directory]::EnumerateFiles instead of Get-ChildItem + foreach - # to avoid the PS 5.1 pipeline-thread leak that built up over multi-hour - # runs and eventually stalled the detector. See: ~30k leaked threads - # observed after 9h of polling. - $bestPid = $null - $bestMtime = [DateTime]::MinValue - foreach ($f in [System.IO.Directory]::EnumerateFiles($mcodeActiveDir, '*.json')) { - try { - $raw = [System.IO.File]::ReadAllText($f) - $j = $raw | ConvertFrom-Json - $targetPid = [int]$j.pid - $proc = [System.Diagnostics.Process]::GetProcessById($targetPid) - if ($proc -and -not $proc.HasExited) { - # .mcode-active dir is tiny (one or two .json files); prefer the most - # recently started one if multiple happen to exist. - $mtime = [System.IO.File]::GetLastWriteTime($f) - if ($mtime -gt $bestMtime) { - $bestMtime = $mtime - $bestPid = $targetPid - } - } - } catch {} - } - return $bestPid -} - function Get-LatestSessionFile { if (-not $sessionsRoot -or -not (Test-Path $sessionsRoot)) { return $null } # Replace Get-ChildItem -Recurse | Where-Object | Sort-Object | Select-Object @@ -799,30 +752,12 @@ function Write-StatusUnderLock($state, $message, $family, [switch]$MetadataRefre Write-StatusSnapshot -Payload $payload -Previous $prev } -# Get-5hUsage 现在定义在 scripts/lib/Get-5hUsage.ps1(detector 顶部 dot-source 了它). -# 抽到 lib 是为了让 windows-latest CI 能在不装 mcode 的 runner 上跑 5h usage -# 路径(之前 round-9 直接 dot-source 本文件,主循环 init 的 install-root 检查 -# 会在 CI 上炸 "Cannot find mcode install root")。 -# +# 5h 用量的取值与刷新都在 scripts/lib/Get-5hUsage.ps1 里(lib 不在时整条 +# 链路关闭,detector 这边不碰 token、不联网)。 # 字段名契约:fixture 用 model_name / current_interval_remaining_percent / # remains_time(与 lib 的实现一致)。如果实现字段回归,CI step 4 会因为 # Get-5hUsage 返 null 而 fail。 -# 在主循环里每 60s 调一次(用 TTL 守门,单线程安全) -function Refresh-5hUsage { - $now = Get-Date - if (($now - $script:plan5hLastCallAt) -lt $PLAN_API_TTL) { return } - $script:plan5hLastCallAt = $now - $data = Get-5hUsage - if ($null -eq $data) { - $script:plan5hRemainingPct = $null - $script:plan5hResetMs = $null - } else { - $script:plan5hRemainingPct = [int]$data.remainingPct - $script:plan5hResetMs = [int]$data.resetMs - } -} - # 读最新一次 todowrite 的 todos,计算完成百分比 # 缓存:mtime 不变就复用上次结果(典型场景:mcode 跑 1 分钟才动一次 todo) function Get-TodoProgress { @@ -900,30 +835,24 @@ try { $inferred = $null $latestFile = $null - # 1) mcode 进程健康:5s 缓存(避免每秒 Get-ChildItem + Get-Process) - if (($now - $script:lastMcodePidAt) -lt $CACHE_TTL) { - $mcodePid = $script:lastMcodePid + # 1) 最新 session log:5s 缓存(避免每秒 EnumerateFiles 几千个 .jsonl) + # + # 之前这里先查 mcode 进程(读的是 CLI 专属的活动会话目录), + # 查不到就直接判 "mcode 已退出" 并且完全不读 session log。桌面端 + # 没有那个目录,于是状态检测在桌面端永远显示 error。 + # 现在直接以会话流为准;应用真的不在了,由下面"60s 无活动 → idle" + # 的兜底逻辑收尾。 + if (($now - $script:lastLatestFileAt) -lt $CACHE_TTL) { + $latestFile = $script:lastLatestFile } else { - $mcodePid = Get-McodePid - $script:lastMcodePid = $mcodePid - $script:lastMcodePidAt = $now + $latestFile = Get-LatestSessionFile + $script:lastLatestFile = $latestFile + $script:lastLatestFileAt = $now } - if (-not $mcodePid) { - $inferred = @{ state=$S_ERROR; message=$MSG_MCODE_EXIT } - } else { - # 2) 最新 session log:5s 缓存(避免每秒 EnumerateFiles 几千个 .jsonl) - if (($now - $script:lastLatestFileAt) -lt $CACHE_TTL) { - $latestFile = $script:lastLatestFile - } else { - $latestFile = Get-LatestSessionFile - $script:lastLatestFile = $latestFile - $script:lastLatestFileAt = $now - } - if ($latestFile) { - $msg = Read-LastMessage $latestFile - if ($msg) { - $inferred = Infer-State $msg - } + if ($latestFile) { + $msg = Read-LastMessage $latestFile + if ($msg) { + $inferred = Infer-State $msg } } @@ -1001,7 +930,7 @@ try { # 4) 5h 用量:每 60s 刷一次,刷新后若数字变化就写 status.json(让 widget 看到) $prevPct = $script:plan5hRemainingPct $prevMs = $script:plan5hResetMs - Refresh-5hUsage + if ($script:plan5hAvailable) { Refresh-5hUsage } $curPct = $script:plan5hRemainingPct $curMs = $script:plan5hResetMs $usageChanged = ($prevPct -ne $curPct) -or ($prevMs -ne $curMs) -and ($null -ne $curPct) diff --git a/plugins/antianqi/mcode-island/scripts/lib/Get-5hUsage.ps1 b/plugins/antianqi/mcode-island/scripts/lib/Get-5hUsage.ps1 index 97412919..ea5eeb6f 100644 --- a/plugins/antianqi/mcode-island/scripts/lib/Get-5hUsage.ps1 +++ b/plugins/antianqi/mcode-island/scripts/lib/Get-5hUsage.ps1 @@ -1,4 +1,4 @@ -# scripts/lib/Get-5hUsage.ps1 +# scripts/lib/Get-5hUsage.ps1 # # Self-contained 5h usage API client. Exposes one function: # @@ -109,3 +109,53 @@ function Get-5hUsage { return $null } } + +# 5h usage API 节流窗口 +$PLAN_API_TTL = [TimeSpan]::FromSeconds(60) + +# Resolve-PlanToken +# +# 按优先级解析 token,写进 $script:plan5hToken: +# 1. $env:MINIMAX_OAUTH_TOKEN +# 2. $env:MINIMAX_API_KEY +# 3. %APPDATA%\mcode-island\config.json 里的 planApiToken +# +# 三种来源都没有时保持 $null —— Get-5hUsage 会直接返回 $null,一次 +# 网络请求都不发。 +# +# 放在 lib 里而不是 detector 里,是为了让"要不要用 5h 用量"这件事 +# 完全由 lib 的存在与否决定:lib 不在(Marketplace 上架包就不带它), +# detector 既不读 token 也不联网。 +function Resolve-PlanToken { + $script:plan5hToken = $null + if ($env:MINIMAX_OAUTH_TOKEN) { $script:plan5hToken = $env:MINIMAX_OAUTH_TOKEN; return } + if ($env:MINIMAX_API_KEY) { $script:plan5hToken = $env:MINIMAX_API_KEY; return } + + $cfgPath = Get-Variable -Name 'cfgFile' -ValueOnly -ErrorAction SilentlyContinue + if (-not $cfgPath) { $cfgPath = Join-Path $env:APPDATA 'mcode-island\config.json' } + if (-not (Test-Path $cfgPath)) { return } + try { + $cfg = [System.IO.File]::ReadAllText($cfgPath) | ConvertFrom-Json + if ($cfg.PSObject.Properties['planApiToken'] -and $cfg.planApiToken) { + $script:plan5hToken = [string]$cfg.planApiToken + } + } catch {} +} + +# Refresh-5hUsage +# +# 主循环里每 60s 调一次(TTL 守门,单线程安全),结果写进 +# $script:plan5hRemainingPct / $script:plan5hResetMs。 +function Refresh-5hUsage { + $now = Get-Date + if (($now - $script:plan5hLastCallAt) -lt $PLAN_API_TTL) { return } + $script:plan5hLastCallAt = $now + $data = Get-5hUsage + if ($null -eq $data) { + $script:plan5hRemainingPct = $null + $script:plan5hResetMs = $null + } else { + $script:plan5hRemainingPct = [int]$data.remainingPct + $script:plan5hResetMs = [int]$data.resetMs + } +} From 43c36163af54d7d6e5bd5f8dffee127db25d686b Mon Sep 17 00:00:00 2001 From: antianqi Date: Thu, 22 Oct 2026 22:57:13 +0800 Subject: [PATCH 29/38] fix(mcode-island): the pill re-asserts topmost when another window covers it Topmost="True" in the XAML is evaluated once, when the window is created. Every other SetWindowPos in this file targets the *caller* window, not the pill. So once anything else lands above the pill -- a maximized terminal being activated, a fullscreen app, another always-on-top tool -- the pill stays buried forever. WS_EX_TOPMOST is a persistent style, but it only orders the pill against the topmost band: whichever topmost window was raised last wins, and a plain maximized window that gets re-shown can paint over the pill too. Observed on 2026-09-28: the pill was fully invisible behind Windows Terminal while IsWindowVisible=1, IsIconic=0 and DWMWA_CLOAKED=0, and the widget was still polling. The fix hit-tests the pill's centre pixel on a 1.5s throttle and re-asserts HWND_TOPMOST when the pixel belongs to another process. SWP_NOACTIVATE means it never steals focus from the user's terminal, and the check is skipped entirely while the pill is hidden so it cannot resurrect a pill the user closed. Transient UI that is supposed to be above the pill is exempt: menus and tooltips (#32768), tray balloons (NotifyIconTooltipWindow) and Windows.UI.Core.CoreWindow. Without that exemption the pill would fight every context menu the user opens over it. "mcode-island show" passes -Force, because $window.Show() is a no-op on an already visible WPF window and therefore never restored the z-order on its own. --- .../antianqi/mcode-island/mcode-island.ps1 | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/plugins/antianqi/mcode-island/mcode-island.ps1 b/plugins/antianqi/mcode-island/mcode-island.ps1 index 8df29042..4caff875 100644 --- a/plugins/antianqi/mcode-island/mcode-island.ps1 +++ b/plugins/antianqi/mcode-island/mcode-island.ps1 @@ -98,10 +98,15 @@ 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 bool GetWindowRect(IntPtr hWnd, out RECT lpRect); + [DllImport("user32.dll")] public static extern IntPtr WindowFromPoint(POINT pt); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern int GetClassNameW(IntPtr hWnd, System.Text.StringBuilder lpClassName, int nMaxCount); [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)] + public struct POINT { public int X, Y; } [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] public struct MONITORINFO { public int cbSize; @@ -118,6 +123,27 @@ public class WinAPI { public const uint SWP_NOSIZE = 0x0001; public const uint SWP_NOMOVE = 0x0002; public const uint MONITOR_DEFAULTTONEAREST = 0x00000002; + public const uint SWP_SHOWWINDOW = 0x0040; + + // True when a window owned by another process sits on top of the pill's centre + // pixel. Menus / tooltips / tray balloons are exempt so we never fight + // transient UI that is supposed to be above the pill anyway. + public static bool IsPillCovered(IntPtr self) { + RECT r; + if (!GetWindowRect(self, out r)) return false; + var pt = new POINT { X = (r.Left + r.Right) / 2, Y = (r.Top + r.Bottom) / 2 }; + IntPtr hit = WindowFromPoint(pt); + if (hit == IntPtr.Zero || hit == self) return false; + var sb = new System.Text.StringBuilder(256); + GetClassNameW(hit, sb, 256); + string cls = sb.ToString(); + if (cls.StartsWith("#32768")) return false; // menu / tooltip + if (cls == "NotifyIconTooltipWindow") return false; // tray balloon + if (cls == "Windows.UI.Core.CoreWindow") return false; + uint p; + GetWindowThreadProcessId(hit, out p); + return p != (uint)System.Diagnostics.Process.GetCurrentProcess().Id; + } // 取窗口所在 monitor 的 work area。如果失败返回 (-1,-1)-(-1,-1) 表示无效。 public static RECT GetWorkAreaForWindow(IntPtr hWnd) { @@ -790,6 +816,50 @@ function Flash-Click { $sb.Begin($window) } +# Keep the pill above everything else. +# +# Why this exists: the only thing that ever made the pill topmost is +# Topmost="True" in the XAML, evaluated once at window creation. Every other +# SetWindowPos in this file targets the *caller* window, not the pill. So once +# anything else lands above the pill -- a maximized terminal being activated, a +# fullscreen app, another always-on-top tool -- the pill stays buried forever. +# WS_EX_TOPMOST is a persistent style but only orders the pill against the +# topmost band; whichever topmost window was raised last wins, and a plain +# maximized window that got re-shown can end up painting over it too. +# Observed 2026-09-28: pill fully invisible behind Windows Terminal while +# IsWindowVisible=1, IsIconic=0, DWMWA_CLOAKED=0, widget still polling. +# +# Fix: hit-test the pill centre on a throttle and re-assert HWND_TOPMOST when +# something else owns that pixel. SWP_NOACTIVATE means this never steals focus +# from the user's terminal. Skipped entirely while the pill is hidden so it +# cannot undo a user-initiated hide. "mcode-island show" passes -Force because +# $window.Show() is a no-op on an already-visible WPF window and therefore +# never restored z-order on its own. +$script:lastTopmostCheck = [DateTime]::MinValue +function Assert-Topmost { + param([switch]$Force) + if (-not $Force -and + [DateTime]::UtcNow - $script:lastTopmostCheck -lt [TimeSpan]::FromMilliseconds(1500)) { + return + } + $script:lastTopmostCheck = [DateTime]::UtcNow + try { + $helper = New-Object System.Windows.Interop.WindowInteropHelper($window) + $h = $helper.Handle + if (-not $h -or -not [WinAPI]::IsWindow($h)) { return } + # Never resurrect a pill the user hid. + if (-not [WinAPI]::IsWindowVisible($h)) { return } + if ($Force -or [WinAPI]::IsPillCovered($h)) { + $flags = [WinAPI]::SWP_NOACTIVATE -bor [WinAPI]::SWP_NOMOVE -bor ` + [WinAPI]::SWP_NOSIZE -bor [WinAPI]::SWP_SHOWWINDOW + [WinAPI]::SetWindowPos($h, [WinAPI]::HWND_TOPMOST, 0, 0, 0, 0, $flags) | Out-Null + Dbg "TOPMOST: re-raised (forced=$([bool]$Force))" + } + } catch { + Dbg "TOPMOST FAIL: $($_.Exception.Message)" + } +} + # 窗口定位 $screen = [System.Windows.SystemParameters]::WorkArea if ($cfg.x -lt 0) { @@ -916,6 +986,8 @@ $showSignalFile = Join-Path $configDir 'show.signal' $timer = New-Object System.Windows.Threading.DispatcherTimer $timer.Interval = [TimeSpan]::FromMilliseconds(400) $timer.Add_Tick({ + # 0) 自愈:被别的窗口压住就重新置顶(内部 1.5s 节流) + Assert-Topmost # 1) 处理 "show" 信号("mcode-island show" 创建这个文件) if (Test-Path $showSignalFile) { try { Remove-Item $showSignalFile -Force -ErrorAction SilentlyContinue } catch {} @@ -923,6 +995,7 @@ $timer.Add_Tick({ $window.Show() $window.WindowState = [System.Windows.WindowState]::Normal $window.Activate() + Assert-Topmost -Force Dbg 'SHOWN via signal file' }) } From a3ec437ba1d4c6ba6222785d83904be5fb8fe31f Mon Sep 17 00:00:00 2001 From: antianqi Date: Thu, 22 Oct 2026 23:22:18 +0800 Subject: [PATCH 30/38] fix(mcode-island): the 5h usage path stops hiding strings, impersonating a surface, and grabbing credentials The Marketplace review rejected the mcode-island submission for five reasons. Two of them were packaging rules, but three were about how this plugin handles the 5h usage readout, and those applied to the hosted plugin too. This commit fixes them at the source, so the hosted plugin says the same things the package does. 1. The request carried "MM-API-Source: Minimax-MCP", i.e. it claimed to be a MiniMax-owned surface. A probe without any header, with "MM-API-Source: Minimax-MCP" and with "MM-API-Source: mcode-island" all return the same 1004 "Please carry the API secret key in the 'Authorization' field", so the endpoint never read the header. It only claimed a MiniMax identity. The header is gone; the request carries Authorization and nothing else. 2. The endpoint and 32 other strings were assembled from byte arrays through a private `_s` helper. The stated reason was a PowerShell 5.1 parser quirk, but every one of those constants is assigned to a variable and only ever compared as a variable, so the literal never sits after an -eq where the quirk applies. Replacing them with plain literals parses without an error under Windows PowerShell 5.1 and behaves the same, and the `_s` helper is deleted with them. The endpoint is now a plain string in the lib, and the README names the same host, so the disclosure check can compare the two directly instead of decoding one to check the other. 3. The token was stored in the clear in config.json, and an ambient MINIMAX_OAUTH_TOKEN / MINIMAX_API_KEY was picked up and sent without the user ever opting in. The token is now encrypted with DPAPI (CurrentUser scope) and stored as config.json:planApiTokenEnc. Only the same Windows user on the same machine can decrypt it. A config.json written by an older version, which kept the token in the clear under planApiToken, is migrated in place on the next detector start: the token is encrypted and the plaintext field is removed. No action is required from the user. The environment variables are only read when the user has explicitly opted in with `set-token.ps1 -AllowEnvToken`, which persists allowEnvToken=true in config.json. An environment variable that happens to be set is not consent to send it to an API. The existing configuration keeps working unchanged: it uses the config.json token, not the environment. The request goes to the user's own MiniMax account endpoint and nothing else is sent. The README now says exactly that. Validation * node plugins/antianqi/mcode-island/scripts/smoke.mjs -> 131 pass, 7 warn, 0 fail * pwsh -File plugins/antianqi/mcode-island/scripts/test-windows-workflow-local.ps1 -> all 5 steps OK (step 5: 116 pass, 0 fail) * Windows PowerShell 5.1: mcode-status-detect.ps1 parses without an error and -Once still infers the state from the session log * token migration on a config copy: planApiToken (plaintext) became planApiTokenEnc (DPAPI), the plaintext field is gone and the 5h readout still works (usage5h=70) Test evidence * the smoke test gained the checks that would have caught this: no .ps1 may send an MM-API-Source header, no .ps1 may build a string from a byte array, set-token.ps1 must store planApiTokenEnc and never write a plaintext planApiToken, and the environment token must sit behind the allowEnvToken gate * the CI local runner exercises the real resolution path (Resolve-PlanToken) for all three token sources: the env token after an explicit opt-in, the DPAPI-encrypted config token, and no token at all * the smoke test's disclosure check now reads the plain endpoint and compares it against every host the README and the Skill name Design compliance * the credential is encrypted at rest, is never logged, is never sent anywhere but api.minimaxi.com, and is only taken from the environment with the user's explicit consent * the request no longer claims a MiniMax-owned identity * nothing in the plugin is hidden from a reader anymore --- plugins/antianqi/mcode-island/README.md | 33 +++-- .../mcode-island/mcode-status-detect.ps1 | 72 ++++++----- .../mcode-island/scripts/lib/Get-5hUsage.ps1 | 94 +++++++++----- .../scripts/lib/Protect-Token.ps1 | 88 +++++++++++++ .../antianqi/mcode-island/scripts/smoke.mjs | 121 +++++++++++++++--- .../scripts/test-windows-workflow-local.ps1 | 65 ++++++---- plugins/antianqi/mcode-island/set-token.ps1 | 108 ++++++++++++---- 7 files changed, 442 insertions(+), 139 deletions(-) create mode 100644 plugins/antianqi/mcode-island/scripts/lib/Protect-Token.ps1 diff --git a/plugins/antianqi/mcode-island/README.md b/plugins/antianqi/mcode-island/README.md index 721f914c..09c6afb9 100644 --- a/plugins/antianqi/mcode-island/README.md +++ b/plugins/antianqi/mcode-island/README.md @@ -265,16 +265,16 @@ binary, no symlink, no `node_modules`. | `status.json` | `%APPDATA%\mcode-island\` | rewritten every transition | widget polling | | `caller.json` | `%APPDATA%\mcode-island\` | rewritten every transition | click-to-focus target HWND | | `config.json` | `%APPDATA%\mcode-island\` | rewritten on drag | pill position, size, opacity | -| `config.json` -> `planApiToken` | `%APPDATA%\mcode-island\` | until `-Clear` or manual edit | 5h usage API token (opt-in; see "Accounts") | +| `config.json` -> `planApiTokenEnc` | `%APPDATA%\mcode-island\` | until `-Clear` or manual edit | 5h usage API token, DPAPI-encrypted (opt-in; see "Accounts") | | `widget.pid` | `%APPDATA%\mcode-island\` | rewritten on start | widget process PID | | `island.log` | `%APPDATA%\mcode-island\` | append-only, never pruned | state transition history | | `widget.log` | `%APPDATA%\mcode-island\` | append-only, never pruned | widget internal debug | | `show.signal` | `%APPDATA%\mcode-island\` | transient | "raise hidden window" signal | | `HKCU\...\Run` | Windows registry | until disabled | logon auto-start | -**Telemetry: none.** **No data is sent off-machine unless the optional -`planApiToken` is configured (see "Network access" below).** The widget -itself is offline and never reads or writes anything outside `%APPDATA%\mcode-island\`. +**Telemetry: none.** **No data is sent off-machine unless a token is +configured (see "Network access" below).** The widget itself is offline +and never reads or writes anything outside `%APPDATA%\mcode-island\`. ## Network access @@ -282,8 +282,12 @@ The widget is fully offline. The only network caller in this plugin is `mcode-status-detect.ps1` (Mode B detector), and it only makes a request when ALL of the following are true: -1. A token is configured (env `MINIMAX_OAUTH_TOKEN` or `MINIMAX_API_KEY`, - or `set-token.ps1 ` which writes to `config.json:planApiToken`). +1. A token is configured — either `set-token.ps1 `, which encrypts + it into `config.json:planApiTokenEnc`, or an environment variable + (`MINIMAX_OAUTH_TOKEN` / `MINIMAX_API_KEY`) that the user has + explicitly opted in to with `set-token.ps1 -AllowEnvToken`. An ambient + environment variable is not consent to send it to an API, so the + environment is not read by default. 2. The detector is running (`mcode-island detect-on`, the default). 3. At least 60 seconds have elapsed since the last call (rate-limited). @@ -309,9 +313,9 @@ the 5-hour usage readout in the pill. | token type | how it enters the plugin | where it is stored | how it is removed | | ----------------- | -------------------------------------------------------- | -------------------------------------------------- | -------------------------------------------- | -| `MINIMAX_OAUTH_TOKEN` (env) | set by the user in their shell or mcode config | process env (not on disk) | unset env / close shell | -| `MINIMAX_API_KEY` (env) | same as above | process env | same as above | -| `config.json:planApiToken` | `set-token.ps1 ` | `%APPDATA%\mcode-island\config.json` (plaintext) | `set-token.ps1 -Clear` or edit the file | +| `MINIMAX_OAUTH_TOKEN` (env) | set by the user in their shell or mcode config | process env (not on disk), read only after `set-token.ps1 -AllowEnvToken` | unset env / close shell / `set-token.ps1 -NoEnvToken` | +| `MINIMAX_API_KEY` (env) | same as above | process env, same opt-in | same as above | +| `config.json:planApiTokenEnc` | `set-token.ps1 ` | `%APPDATA%\mcode-island\config.json`, DPAPI-encrypted (CurrentUser scope) | `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.minimaxi.com`**. `set-token.ps1` only writes @@ -319,6 +323,17 @@ 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. +The token is stored encrypted with Windows DPAPI (CurrentUser scope): +only the same Windows user on the same machine can decrypt it, so +copying `config.json` elsewhere yields nothing usable. A `config.json` +written by an older version, which kept the token in the clear under +`planApiToken`, is migrated in place on the next detector start: the +token is encrypted and the plaintext field is removed. No action is +required from the user. + +The request carries the `Authorization` header and nothing else. It does +not claim to be a MiniMax-owned surface. + ## CLI reference ```cmd diff --git a/plugins/antianqi/mcode-island/mcode-status-detect.ps1 b/plugins/antianqi/mcode-island/mcode-status-detect.ps1 index 5a24ba0b..e699886f 100644 --- a/plugins/antianqi/mcode-island/mcode-status-detect.ps1 +++ b/plugins/antianqi/mcode-island/mcode-status-detect.ps1 @@ -26,9 +26,12 @@ # detector 推的 idle / error 可以覆盖 agent 推的(兜底) # detector 推的 working/thinking/done 不覆盖 agent 推的 # -# PS 5.1 parser quirk: -# 短 ASCII 字符串字面量在 -eq / state= 后会被解析为 cmdlet 参数 -# 所以所有 ASCII 字符串都用 _s (byte[]) helper 构造 +# 字符串常量 +# 以前这里用 _s (byte[]) 构造,理由是"PS 5.1 在 -eq / state= 后面会把短的 +# ASCII 字面量当成 cmdlet 参数"。实测下来这些常量都是先赋给变量、后面 +# 只拿变量去比较,字面量从不直接出现在 -eq 后面,所以明文写法在 PS 5.1 +# 下解析 0 错误、行为一致(Windows PowerShell 5.1 实跑 -Once 验证过)。 +# 字节数组只会让常量对阅读者不可见,已全部改回明文。 param( [switch]$Once, @@ -55,42 +58,41 @@ if ($hwnd -ne [IntPtr]::Zero) { [void][DetectHide]::ShowWindow($hwnd, 0) } -function _s { param([byte[]]$b) [System.Text.Encoding]::UTF8.GetString($b) } # role names -$S_USER = _s (0x75,0x73,0x65,0x72) -$S_ASSISTANT = _s (0x61,0x73,0x73,0x69,0x73,0x74,0x61,0x6E,0x74) -$S_TOOLRESULT = _s (0x74,0x6F,0x6F,0x6C,0x52,0x65,0x73,0x75,0x6C,0x74) -$S_COMPACTION = _s (0x63,0x6F,0x6D,0x70,0x61,0x63,0x74,0x69,0x6F,0x6E,0x53,0x75,0x6D,0x6D,0x61,0x72,0x79) +$S_USER = 'user' +$S_ASSISTANT = 'assistant' +$S_TOOLRESULT = 'toolResult' +$S_COMPACTION = 'compactionSummary' # state values -$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) +$S_IDLE = 'idle' +$S_THINKING = 'thinking' +$S_WORKING = 'working' +$S_WAITING = 'waiting' +$S_DONE = 'done' +$S_ERROR = 'error' # content type discriminators -$S_TOOLCALL = _s (0x74,0x6F,0x6F,0x6C,0x43,0x61,0x6C,0x6C) -$S_TEXT = _s (0x74,0x65,0x78,0x74) -$S_THINK_BLK = _s (0x74,0x68,0x69,0x6E,0x6B,0x69,0x6E,0x67) +$S_TOOLCALL = 'toolCall' +$S_TEXT = 'text' +$S_THINK_BLK = 'thinking' # source tag -$S_DETECTOR = _s (0x64,0x65,0x74,0x65,0x63,0x74,0x6F,0x72) +$S_DETECTOR = 'detector' # 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_COMPACT = _s (0x73,0x65,0x73,0x73,0x69,0x6F,0x6E,0x20,0xE5,0x8E,0x8B,0xE7,0xBC,0xA9) # session 压缩 -$MSG_IDLE_FMT = _s (0xE5,0xB7,0xB2,0xE9,0x9D,0x99,0xE9,0xBB,0x98,0x20,0x7B,0x30,0x7D,0x73) # 已静默 {0}s -$DOTS = _s (0x2E,0x2E,0x2E) # ... -$FMT_O = _s (0x6F) # 'o' (ISO 8601 timestamp) -$FMT_O_FULL = _s (0x4F) # 'O' (ISO 8601 full) +$MSG_USER_WAIT = '等用户' # 等用户 +$MSG_AGENT_DONE = 'agent 刚回复,等用户' # agent 刚回复,等用户 +$MSG_COMPACT = 'session 压缩' # session 压缩 +$MSG_IDLE_FMT = '已静默 {0}s' # 已静默 {0}s +$DOTS = '...' # ... +$FMT_O = 'o' # 'o' (ISO 8601 timestamp) +$FMT_O_FULL = 'O' # 'O' (ISO 8601 full) # log reason tags -$R_NO_CURRENT = _s (0x6E,0x6F,0x20,0x63,0x75,0x72,0x72,0x65,0x6E,0x74,0x20,0x73,0x74,0x61,0x74,0x75,0x73) -$R_OWN_CHANGED = _s (0x6F,0x77,0x6E,0x20,0x73,0x74,0x61,0x74,0x65,0x20,0x63,0x68,0x61,0x6E,0x67,0x65,0x64) -$R_OWN_MSG = _s (0x6F,0x77,0x6E,0x20,0x6D,0x65,0x73,0x73,0x61,0x67,0x65,0x20,0x63,0x68,0x61,0x6E,0x67,0x65,0x64) -$R_TAKEOVER = _s (0x74,0x61,0x6B,0x65,0x6F,0x76,0x65,0x72,0x20,0x74,0x6F,0x20,0x73,0x65,0x74,0x74,0x6C,0x65) +$R_NO_CURRENT = 'no current status' +$R_OWN_CHANGED = 'own state changed' +$R_OWN_MSG = 'own message changed' +$R_TAKEOVER = 'takeover to settle' # 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 +$FNAME_LEDGER = 'ledger.jsonl' # ledger.jsonl +$FNAME_MESSAGES = 'messages.jsonl' # 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 @@ -134,10 +136,10 @@ $TOOL_FAMILIES = @{ '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 +$TOOL_FALLBACK_RUNNING = 'Using' # Using +$TOOL_FALLBACK_DONE = 'Used' # Used # v2 sessions relative to user profile -$V2_SESSIONS_REL = _s (0x2E,0x6D,0x69,0x6E,0x69,0x6D,0x61,0x78,0x5C,0x76,0x32,0x5C,0x73,0x65,0x73,0x73,0x69,0x6F,0x6E,0x73) # .minimax\v2\sessions +$V2_SESSIONS_REL = '.minimax\v2\sessions' # .minimax\v2\sessions $configDir = Join-Path $env:APPDATA 'mcode-island' if (!(Test-Path $configDir)) { New-Item -ItemType Directory -Path $configDir -Force | Out-Null } @@ -979,6 +981,6 @@ try { if (-not $Once -and (Test-Path $pidFile)) { Remove-Item $pidFile -Force -ErrorAction SilentlyContinue } - $STOPPED_MSG = _s (0x64,0x65,0x74,0x65,0x63,0x74,0x6F,0x72,0x20,0x73,0x74,0x6F,0x70,0x70,0x65,0x64) + $STOPPED_MSG = 'detector stopped' Log-Line $STOPPED_MSG } diff --git a/plugins/antianqi/mcode-island/scripts/lib/Get-5hUsage.ps1 b/plugins/antianqi/mcode-island/scripts/lib/Get-5hUsage.ps1 index ea5eeb6f..d010c121 100644 --- a/plugins/antianqi/mcode-island/scripts/lib/Get-5hUsage.ps1 +++ b/plugins/antianqi/mcode-island/scripts/lib/Get-5hUsage.ps1 @@ -34,13 +34,15 @@ # # Contract for callers # # * `$script:plan5hToken` (script-scope string) must be set by -# the caller BEFORE calling `Get-5hUsage`. Three acceptable -# sources, in precedence order: -# 1. $env:MINIMAX_OAUTH_TOKEN -# 2. $env:MINIMAX_API_KEY -# 3. (config.json).planApiToken at $cfgFile -# When unset, the function returns $null at line 67 without -# touching the network. +# the caller BEFORE calling `Get-5hUsage`. `Resolve-PlanToken` +# (below) does that for the detector. Two acceptable sources: +# 1. config.json -> planApiTokenEnc, the DPAPI-encrypted blob +# 2. $env:MINIMAX_OAUTH_TOKEN / $env:MINIMAX_API_KEY, and only +# when the user has explicitly opted in with +# `set-token.ps1 -AllowEnvToken` (config.json -> +# allowEnvToken). An ambient environment variable is not +# consent to send it to an API. +# When unset, the function returns $null without touching the network. # # * `$script:PLAN_API_HOST` and `$script:PLAN_API_PATH` are # exposed for tests that want to redirect the request to a @@ -53,16 +55,16 @@ # silently no-op because the only callable is `Get-5hUsage` # and there is no entry point. -# PS 5.1 parser quirk (same as mcode-status-detect.ps1): -# short ASCII string literals at the end of `-eq` / `= state=` -# lines get parsed as cmdlet arguments. The URL/host bytes are -# obfuscated through a private `_s` helper to keep the -# production endpoint out of a naive `grep`. -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.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 +# +# Plain text on purpose: the endpoint used to be assembled from a byte +# array so that a naive `grep` for the host would not find it. That is +# indistinguishable from hiding a string from review, and it made the +# disclosure in README.md unverifiable. The endpoint is the user's own +# MiniMax account endpoint and is named in the README, so it is written +# out. +$PLAN_API_HOST = 'https://api.minimaxi.com' +$PLAN_API_PATH = '/v1/coding_plan/remains' # Get-5hUsage # Reads $script:plan5hToken from the caller's scope. Returns @@ -83,9 +85,14 @@ function Get-5hUsage { [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12 } $url = $PLAN_API_HOST + $PLAN_API_PATH + # Only the Authorization header. The endpoint identifies the + # credential, not the client: a probe without any header, with + # "MM-API-Source: Minimax-MCP" and with "MM-API-Source: mcode-island" + # all return the same 1004 "Please carry the API secret key in the + # 'Authorization' field", so the source header was never read. It + # only claimed to be a MiniMax-owned surface, which it is not. $headers = @{ 'Authorization' = "Bearer $($script:plan5hToken)" - 'MM-API-Source' = _s (0x4D,0x69,0x6E,0x69,0x6D,0x61,0x78,0x2D,0x4D,0x43,0x50) # Minimax-MCP } $resp = Invoke-RestMethod -Uri $url -Headers $headers -TimeoutSec 8 -Method Get -ErrorAction Stop if (-not $resp -or -not $resp.model_remains) { return $null } @@ -115,10 +122,13 @@ $PLAN_API_TTL = [TimeSpan]::FromSeconds(60) # Resolve-PlanToken # -# 按优先级解析 token,写进 $script:plan5hToken: -# 1. $env:MINIMAX_OAUTH_TOKEN -# 2. $env:MINIMAX_API_KEY -# 3. %APPDATA%\mcode-island\config.json 里的 planApiToken +# 解析 token,写进 $script:plan5hToken。优先级: +# 1. config.json 的 planApiTokenEnc(DPAPI 加密,CurrentUser 作用域) +# 2. 环境变量 MINIMAX_OAUTH_TOKEN / MINIMAX_API_KEY +# —— 只有用户在 config.json 里显式 allowEnvToken=true 时才读。 +# 环境里恰好有变量不等于同意把它发出去。 +# 3. 旧版本留下的明文 config.json:planApiToken +# —— 读到的同时就地迁移成加密形式,明文当场删除。 # # 三种来源都没有时保持 $null —— Get-5hUsage 会直接返回 $null,一次 # 网络请求都不发。 @@ -126,20 +136,46 @@ $PLAN_API_TTL = [TimeSpan]::FromSeconds(60) # 放在 lib 里而不是 detector 里,是为了让"要不要用 5h 用量"这件事 # 完全由 lib 的存在与否决定:lib 不在(Marketplace 上架包就不带它), # detector 既不读 token 也不联网。 +. "$PSScriptRoot/Protect-Token.ps1" + function Resolve-PlanToken { $script:plan5hToken = $null - if ($env:MINIMAX_OAUTH_TOKEN) { $script:plan5hToken = $env:MINIMAX_OAUTH_TOKEN; return } - if ($env:MINIMAX_API_KEY) { $script:plan5hToken = $env:MINIMAX_API_KEY; return } $cfgPath = Get-Variable -Name 'cfgFile' -ValueOnly -ErrorAction SilentlyContinue if (-not $cfgPath) { $cfgPath = Join-Path $env:APPDATA 'mcode-island\config.json' } - if (-not (Test-Path $cfgPath)) { return } - try { - $cfg = [System.IO.File]::ReadAllText($cfgPath) | ConvertFrom-Json - if ($cfg.PSObject.Properties['planApiToken'] -and $cfg.planApiToken) { - $script:plan5hToken = [string]$cfg.planApiToken + + $cfg = $null + if (Test-Path $cfgPath) { + try { $cfg = [System.IO.File]::ReadAllText($cfgPath) | ConvertFrom-Json } catch { $cfg = $null } + } + + # 1) DPAPI 加密的 token + if ($cfg -and $cfg.PSObject.Properties['planApiTokenEnc'] -and $cfg.planApiTokenEnc) { + $plain = Unprotect-Token ([string]$cfg.planApiTokenEnc) + if ($plain) { $script:plan5hToken = $plain; return } + } + + # 2) 环境变量:必须用户显式开过 + if ($cfg -and $cfg.PSObject.Properties['allowEnvToken'] -and $cfg.allowEnvToken) { + if ($env:MINIMAX_OAUTH_TOKEN) { $script:plan5hToken = $env:MINIMAX_OAUTH_TOKEN; return } + if ($env:MINIMAX_API_KEY) { $script:plan5hToken = $env:MINIMAX_API_KEY; return } + } + + # 3) 旧版明文 token:就地迁移成密文 + if ($cfg -and $cfg.PSObject.Properties['planApiToken'] -and $cfg.planApiToken) { + $legacy = [string]$cfg.planApiToken + $script:plan5hToken = $legacy + $enc = Protect-Token $legacy + if ($enc) { + try { + $cfg.PSObject.Properties.Remove('planApiToken') + $cfg | Add-Member -NotePropertyName 'planApiTokenEnc' -NotePropertyValue $enc -Force + # 原子替换:先写新文件,再换名。直接 WriteAllText 的话,中途失败 + # 会把 config.json 截断,用户的 token 就没了。 + Write-AtomicText -Path $cfgPath -Content ($cfg | ConvertTo-Json -Depth 10) + } catch {} } - } catch {} + } } # Refresh-5hUsage diff --git a/plugins/antianqi/mcode-island/scripts/lib/Protect-Token.ps1 b/plugins/antianqi/mcode-island/scripts/lib/Protect-Token.ps1 new file mode 100644 index 00000000..91d63a0d --- /dev/null +++ b/plugins/antianqi/mcode-island/scripts/lib/Protect-Token.ps1 @@ -0,0 +1,88 @@ +# scripts/lib/Protect-Token.ps1 +# +# DPAPI (Windows Data Protection API) helpers for the 5h usage token. +# +# The token used for the optional 5-hour usage readout is a credential, so +# it is never stored in the clear: +# +# * set-token.ps1 encrypts it with Protect-Token and writes the base64 +# blob to config.json as `planApiTokenEnc` +# * Resolve-PlanToken (in Get-5hUsage.ps1) decrypts it with Unprotect-Token +# +# The CurrentUser scope means only the same Windows user on the same +# machine can decrypt it. Copying config.json to another machine, or to +# another user, yields nothing usable. +# +# The functions are dot-source-only; there is no entry point. + +function Test-TokenProtection { + # Returns $true when DPAPI is usable in this PowerShell host. + try { + Add-Type -AssemblyName System.Security -ErrorAction Stop + return $true + } catch { + return $false + } +} + +function Protect-Token { + # Encrypts $Plain with DPAPI (CurrentUser) and returns a base64 blob, + # or $null when DPAPI is unavailable or encryption failed. + param([string]$Plain) + + if (-not $Plain) { return $null } + if (-not (Test-TokenProtection)) { return $null } + try { + $bytes = [System.Text.Encoding]::UTF8.GetBytes($Plain) + $blob = [System.Security.Cryptography.ProtectedData]::Protect( + $bytes, $null, [System.Security.Cryptography.DataProtectionScope]::CurrentUser) + return [Convert]::ToBase64String($blob) + } catch { + return $null + } +} + +function Unprotect-Token { + # Decrypts a base64 blob produced by Protect-Token and returns the + # plaintext, or $null when DPAPI is unavailable or the blob cannot be + # decrypted (wrong user, different machine, corrupted). + param([string]$Encoded) + + if (-not $Encoded) { return $null } + if (-not (Test-TokenProtection)) { return $null } + try { + $blob = [Convert]::FromBase64String($Encoded) + $bytes = [System.Security.Cryptography.ProtectedData]::Unprotect( + $blob, $null, [System.Security.Cryptography.DataProtectionScope]::CurrentUser) + return [System.Text.Encoding]::UTF8.GetString($bytes) + } catch { + return $null + } +} + +function Write-AtomicText { + # Writes $Content to $Path through a staging file and a rename, so a + # failure never truncates the file it replaces. config.json holds the + # token, and a truncated config means the token is gone. + # + # The rename is retried because MoveFileEx with REPLACE_EXISTING fails + # when the target is open by a reader that did not share delete. + param( + [string]$Path, + [string]$Content + ) + + $tmp = "$Path.tmp" + [System.IO.File]::WriteAllText($tmp, $Content, (New-Object System.Text.UTF8Encoding($false))) + $attempt = 0 + while ($true) { + try { + Move-Item -Path $tmp -Destination $Path -Force + break + } catch { + $attempt++ + if ($attempt -ge 20) { throw } + Start-Sleep -Milliseconds 25 + } + } +} diff --git a/plugins/antianqi/mcode-island/scripts/smoke.mjs b/plugins/antianqi/mcode-island/scripts/smoke.mjs index 34fddb60..c9914eb1 100644 --- a/plugins/antianqi/mcode-island/scripts/smoke.mjs +++ b/plugins/antianqi/mcode-island/scripts/smoke.mjs @@ -663,7 +663,7 @@ const main = async () => { // 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*\(/], + ['$S_WAITING is defined', /\$S_WAITING\s*=\s*(?:_s\s*\(|')/], ['Test-IsAskUser helper', /function Test-IsAskUser\s*\(/], ['Get-AskQuestionCount helper', /function Get-AskQuestionCount\s*\(/], ]) { @@ -932,13 +932,15 @@ const main = async () => { // // 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. + // the code actually calls. The endpoint used to be obfuscated into a + // byte array, so this check DECODED that array and compared the docs + // against the decoded truth. The endpoint is plain text now -- the + // byte array only hid it from a grep, which is indistinguishable from + // hiding it from review -- so the check reads the literal. It still + // 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 @@ -948,21 +950,23 @@ const main = async () => { 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*\(([^)]*)\)/); + 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)'); + out('FAIL', 'Get-5hUsage.ps1: cannot locate the $PLAN_API_HOST constant (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'); + const realUrl = hostArr[2] !== undefined + ? hostArr[2] + : Buffer.from( + hostArr[1].split(',') + .map(s => parseInt(s.trim().replace(/^0x/i, ''), 16)) + .filter(n => Number.isFinite(n)) + ).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}")`); + out('FAIL', `Get-5hUsage.ps1: $PLAN_API_HOST is not a parseable URL ("${realUrl}")`); } else { - out('PASS', `Get-5hUsage.ps1: $PLAN_API_HOST decodes to a valid host (${realHost})`); + out('PASS', `Get-5hUsage.ps1: $PLAN_API_HOST names a valid host (${realHost})`); const namedHosts = [ 'README.md', @@ -995,6 +999,89 @@ const main = async () => { out('FAIL', 'scripts/lib/Get-5hUsage.ps1 missing (disclosure check skipped)'); } + // 7b. Credential handling (round-21). + // + // The Marketplace review rejected an earlier submission for three + // credential problems in the 5h usage path, and none of them is + // visible in a file listing: + // + // * the request carried "MM-API-Source: Minimax-MCP", i.e. it + // claimed to be a MiniMax-owned surface; + // * the endpoint and several strings were assembled from byte + // arrays, which reads as hiding them from review; + // * the token was stored in the clear in config.json, and an + // ambient environment token was picked up and sent without the + // user ever opting in. + // + // These checks make the contract explicit, so a later edit cannot + // quietly reintroduce any of them. + { + const ps1Files = []; + const walkPs1 = async (dir) => { + let entries = []; + try { entries = await readdir(dir, { withFileTypes: true }); } catch { return; } + for (const e of entries) { + const abs = join(dir, e.name); + if (e.isDirectory()) await walkPs1(abs); + else if (e.name.endsWith('.ps1')) ps1Files.push(abs); + } + }; + await walkPs1(PLUGIN_ROOT); + + let sourceHeader = 0, byteArray = 0; + for (const abs of ps1Files) { + const rel = abs.slice(PLUGIN_ROOT.length + 1).replace(/\\/g, '/'); + const text = await readFile(abs, 'utf8'); + // A header assignment, not a mention in a comment: the + // comment that explains why the header was removed is + // allowed to name it. + if (/['"]MM-API-Source['"]\s*=/.test(text)) { + out('FAIL', `${rel}: sends an MM-API-Source header (it claims to be a MiniMax-owned surface)`); + sourceHeader++; + } + if (/_s\s*\(\s*0x/i.test(text)) { + out('FAIL', `${rel}: builds a string from a byte array (hides it from review)`); + byteArray++; + } + } + if (sourceHeader === 0) out('PASS', 'no .ps1 sends an MM-API-Source header'); + if (byteArray === 0) out('PASS', 'no .ps1 builds a string from a byte array'); + + const setTokenAbs = join(PLUGIN_ROOT, 'set-token.ps1'); + if (await exists(setTokenAbs)) { + const st = await readFile(setTokenAbs, 'utf8'); + if (st.includes('planApiTokenEnc')) { + out('PASS', 'set-token.ps1 stores the token as planApiTokenEnc (encrypted)'); + } else { + out('FAIL', 'set-token.ps1 does not store the token as planApiTokenEnc'); + } + // A plaintext write is the thing the review rejected. The + // legacy field may still be READ (to migrate it), but it must + // never be written back. + const writesPlain = /NotePropertyValue\s+'?\$?Token'|planApiToken'\s*\]\s*=\s*\$Token/.test(st); + const addsPlain = /Add-Member[^\n]*NotePropertyName\s+'planApiToken'/.test(st); + if (writesPlain || addsPlain) { + out('FAIL', 'set-token.ps1 writes a plaintext planApiToken'); + } else { + out('PASS', 'set-token.ps1 never writes a plaintext planApiToken'); + } + } + + if (await exists(planLibAbs)) { + const lib = await readFile(planLibAbs, 'utf8'); + if (lib.includes('allowEnvToken')) { + out('PASS', 'Get-5hUsage.ps1 gates the environment token behind allowEnvToken'); + } else { + out('FAIL', 'Get-5hUsage.ps1 reads the environment token without an allowEnvToken gate'); + } + if (lib.includes('planApiTokenEnc')) { + out('PASS', 'Get-5hUsage.ps1 reads the token from planApiTokenEnc'); + } else { + out('FAIL', 'Get-5hUsage.ps1 does not read the encrypted planApiTokenEnc'); + } + } + } + // 8. Log growth is bounded (round-20). // // Contract: the two append-only logs under %APPDATA%\mcode-island\ 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 4f9abc2a..b6ec7205 100644 --- a/plugins/antianqi/mcode-island/scripts/test-windows-workflow-local.ps1 +++ b/plugins/antianqi/mcode-island/scripts/test-windows-workflow-local.ps1 @@ -95,6 +95,11 @@ Write-Host "" Write-Host "--- Step 2: token set / show / clear roundtrip ---" $set = Join-Path $repoRoot 'plugins/antianqi/mcode-island/set-token.ps1' +# The DPAPI helpers live in their own lib; step 2 needs them to check that +# what set-token.ps1 wrote really decrypts back to the token. +$protectTokenLib = Join-Path $repoRoot 'plugins/antianqi/mcode-island/scripts/lib/Protect-Token.ps1' +if (-not (Test-Path $protectTokenLib)) { throw "Step 2: $protectTokenLib not found" } +. $protectTokenLib # 2a $r1 = (& $set $FAKE | Out-String).Trim() @@ -102,11 +107,14 @@ if ($r1 -notmatch '^已写入') { throw "Step 2a: expected '已写入' header, g $cfgFile = Join-Path $apphome.FullName 'mcode-island\config.json' if (-not (Test-Path $cfgFile)) { throw "Step 2a: $cfgFile not written" } $cfg = Get-Content $cfgFile -Raw | ConvertFrom-Json -if ($cfg.planApiToken -ne $FAKE) { throw "Step 2a: config.json planApiToken mismatch" } +if ($cfg.PSObject.Properties['planApiToken']) { throw "Step 2a: config.json still has a plaintext planApiToken" } +if (-not $cfg.PSObject.Properties['planApiTokenEnc']) { throw "Step 2a: config.json has no planApiTokenEnc" } +$decrypted = Unprotect-Token ([string]$cfg.planApiTokenEnc) +if ($decrypted -ne $FAKE) { throw "Step 2a: planApiTokenEnc does not decrypt back to the token" } # 2b $r2 = (& $set -Show | Out-String).Trim() -if ($r2 -notmatch 'config\.json planApiToken') { throw "Step 2b: expected 'config.json planApiToken', got: $r2" } +if ($r2 -notmatch 'config\.json planApiTokenEnc') { throw "Step 2b: expected 'config.json planApiTokenEnc', got: $r2" } $expectedMask = $FAKE.Substring(0, [Math]::Min(10, $FAKE.Length)) + '\.\.\.' if ($r2 -notmatch $expectedMask) { throw "Step 2b: expected masked prefix matching '$expectedMask', got: $r2" } @@ -114,13 +122,14 @@ if ($r2 -notmatch $expectedMask) { throw "Step 2b: expected masked prefix matchi $r3 = (& $set -Clear | Out-String).Trim() if ($r3 -notmatch '已从 config\.json 删除') { throw "Step 2c: expected '已从 config.json 删除', got: $r3" } $cfgAfter = Get-Content $cfgFile -Raw | ConvertFrom-Json +if ($cfgAfter.PSObject.Properties['planApiTokenEnc']) { throw "Step 2c: planApiTokenEnc still present in config.json" } if ($cfgAfter.PSObject.Properties['planApiToken']) { throw "Step 2c: planApiToken still present in config.json" } # 2d $r4 = (& $set -Show | Out-String).Trim() -if ($r4 -ne 'token 未配置') { throw "Step 2d: expected 'token 未配置', got: $r4" } +if ($r4 -notmatch 'token 未配置') { throw "Step 2d: expected 'token 未配置', got: $r4" } -Write-Host "OK Step 2: set / show / clear roundtrip (4 / 4 checks)" +Write-Host "OK Step 2: set / show / clear roundtrip (4 / 4 checks, token stored DPAPI-encrypted)" Write-Host "" # --- Step 3: hook stdin / stdout --------------------------------------- @@ -221,54 +230,62 @@ function Run-MockOneRequest { return ,$j } -# Test a: env-var token wins; implementation must read the -# new field names; return value must be the documented -# { remainingPct, resetMs } shape. +# Test a: env-var token, but only because the user opted in +# (config.json -> allowEnvToken = true). The token resolution goes +# through Resolve-PlanToken, i.e. the same path the detector uses. $jobA = Run-MockOneRequest $freePort $fixtureBody try { $env:MINIMAX_OAUTH_TOKEN = $FAKE - $script:plan5hToken = $FAKE + $cfgDirA = Join-Path $apphome 'mcode-island' + if (-not (Test-Path $cfgDirA)) { New-Item -ItemType Directory -Path $cfgDirA -Force | Out-Null } + @{ allowEnvToken = $true } | ConvertTo-Json | + Out-File -FilePath (Join-Path $cfgDirA 'config.json') -Encoding utf8 + $cfgFile = Join-Path $cfgDirA 'config.json' + $script:plan5hToken = $null + Resolve-PlanToken + if ($script:plan5hToken -ne $FAKE) { + throw "Step 4a: Resolve-PlanToken did not pick up the env token (got '$($script:plan5hToken)')" + } $data = Get-5hUsage $mock = $jobA | Wait-Job -Timeout 15 | Receive-Job if (-not $mock) { throw "Step 4a: listener did not complete (state $($jobA.State))" } if ($mock.auth -ne "Bearer $FAKE") { throw "Step 4a: auth='$($mock.auth)' (want 'Bearer $FAKE')" } if ($mock.path -ne '/v1/coding_plan/remains') { throw "Step 4a: path='$($mock.path)'" } - if ($null -eq $data) { throw "Step 4a: Get-5hUsage returned `$null` (fixture field-name contract broken)" } + if ($null -eq $data) { throw "Step 4a: Get-5hUsage returned `$null (fixture field-name contract broken)" } if ($data.remainingPct -ne 84) { throw "Step 4a: remainingPct=$($data.remainingPct) (want 84)" } if ($data.resetMs -ne 16200000) { throw "Step 4a: resetMs=$($data.resetMs) (want 16200000)" } - Write-Host "OK Step 4a (env token, matching fixture): remainingPct=$($data.remainingPct)% resetMs=$($data.resetMs)" + Write-Host "OK Step 4a (env token after explicit opt-in, matching fixture): remainingPct=$($data.remainingPct)% resetMs=$($data.resetMs)" } finally { if ($jobA.State -ne 'Completed') { Stop-Job $jobA } Remove-Job $jobA -Force } -# Test b: env vars cleared, only config.json. Re-derive -# $script:plan5hToken from config.json the same way the -# detector's top-level init does. Get-5hUsage must pick -# up the config.json token. +# Test b: config-only token, stored DPAPI-encrypted. The config +# carries planApiTokenEnc (never a plaintext token) and the +# resolution goes through Resolve-PlanToken again. $cfgDir = Join-Path $apphome 'mcode-island' if (-not (Test-Path $cfgDir)) { New-Item -ItemType Directory -Path $cfgDir -Force | Out-Null } $cfgToken = 'ci-cfg-only-token-abcdef0123456789' -@{ planApiToken = $cfgToken } | ConvertTo-Json | +$cfgEnc = Protect-Token $cfgToken +if (-not $cfgEnc) { throw "Step 4b: Protect-Token returned null (DPAPI unavailable)" } +@{ planApiTokenEnc = $cfgEnc } | ConvertTo-Json | Out-File -FilePath (Join-Path $cfgDir 'config.json') -Encoding utf8 Remove-Item 'env:MINIMAX_OAUTH_TOKEN' -ErrorAction SilentlyContinue Remove-Item 'env:MINIMAX_API_KEY' -ErrorAction SilentlyContinue -$cfg = Get-Content (Join-Path $cfgDir 'config.json') -Raw | ConvertFrom-Json +$cfgFile = Join-Path $cfgDir 'config.json' $script:plan5hToken = $null -if ($cfg.PSObject.Properties['planApiToken'] -and $cfg.planApiToken) { - $script:plan5hToken = [string]$cfg.planApiToken -} -if ($script:plan5hToken -ne $cfgToken) { throw "Step 4b: config.json planApiToken not picked up (got '$($script:plan5hToken)')" } +Resolve-PlanToken +if ($script:plan5hToken -ne $cfgToken) { throw "Step 4b: config.json planApiTokenEnc not picked up (got '$($script:plan5hToken)')" } $jobB = Run-MockOneRequest $freePort $fixtureBody try { $data = Get-5hUsage $mock = $jobB | Wait-Job -Timeout 15 | Receive-Job if (-not $mock) { throw "Step 4b: listener did not complete (state $($jobB.State))" } if ($mock.auth -ne "Bearer $cfgToken") { throw "Step 4b: auth='$($mock.auth)' (want 'Bearer $cfgToken')" } - if ($null -eq $data) { throw "Step 4b: Get-5hUsage returned `$null` (config-only path broken)" } + if ($null -eq $data) { throw "Step 4b: Get-5hUsage returned `$null (config-only path broken)" } if ($data.remainingPct -ne 84) { throw "Step 4b: remainingPct=$($data.remainingPct) (want 84)" } if ($data.resetMs -ne 16200000) { throw "Step 4b: resetMs=$($data.resetMs) (want 16200000)" } - Write-Host "OK Step 4b (config-only token): remainingPct=$($data.remainingPct)% resetMs=$($data.resetMs)" + Write-Host "OK Step 4b (config-only token, DPAPI encrypted): remainingPct=$($data.remainingPct)% resetMs=$($data.resetMs)" } finally { if ($jobB.State -ne 'Completed') { Stop-Job $jobB } Remove-Job $jobB -Force @@ -282,6 +299,10 @@ try { # would not be contacted. Remove-Item (Join-Path $cfgDir 'config.json') -Force -ErrorAction SilentlyContinue $script:plan5hToken = $null +Resolve-PlanToken +if ($null -ne $script:plan5hToken) { + throw "Step 4c: Resolve-PlanToken found a token with no config and no env (got: $script:plan5hToken)" +} $data = Get-5hUsage if ($null -ne $data) { throw "Step 4c: Get-5hUsage should return null (no token), got: $data" diff --git a/plugins/antianqi/mcode-island/set-token.ps1 b/plugins/antianqi/mcode-island/set-token.ps1 index 2771d3f4..ceb8039a 100644 --- a/plugins/antianqi/mcode-island/set-token.ps1 +++ b/plugins/antianqi/mcode-island/set-token.ps1 @@ -1,20 +1,30 @@ # mcode-island - 设置 5h 用量 API token # 用法: -# set-token.ps1 # 写 token 到 %APPDATA%\mcode-island\config.json -# set-token.ps1 -Show # 显示当前是否已配置 +# set-token.ps1 # 加密保存 token 到 %APPDATA%\mcode-island\config.json +# set-token.ps1 -Show # 显示当前 token 的来源 # set-token.ps1 -Clear # 删除 token +# set-token.ps1 -AllowEnvToken # 允许从环境变量读 token(显式授权) +# set-token.ps1 -NoEnvToken # 禁止从环境变量读 token(默认) # -# token 也可以从环境变量 MINIMAX_OAUTH_TOKEN 自动读,优先级: -# 1. $env:MINIMAX_OAUTH_TOKEN -# 2. $env:MINIMAX_API_KEY -# 3. config.json 的 planApiToken -# 所以通常不用手动 set-token,除非要换 token。 +# token 的存放方式: +# config.json 的 planApiTokenEnc —— 用 Windows DPAPI(CurrentUser 作用域) +# 加密后的 base64。同一个 Windows 用户、同一台机器才能解开;拷到别处没用。 +# +# 环境变量(MINIMAX_OAUTH_TOKEN / MINIMAX_API_KEY)默认**不读**: +# 环境里恰好有个变量,不等于同意把它发到接口上。要用就显式开一次: +# set-token.ps1 -AllowEnvToken +# 写进 config.json 的 allowEnvToken=true。 +# +# 旧版本把 token 明文写在 config.json 的 planApiToken 里。现在读到旧格式 +# 会自动就地迁移成加密格式,明文当场删掉,不用手动处理。 param( [Parameter(Position=0)] [string]$Token, [switch]$Show, - [switch]$Clear + [switch]$Clear, + [switch]$AllowEnvToken, + [switch]$NoEnvToken ) $ErrorActionPreference = 'Stop' @@ -24,27 +34,45 @@ $cfgDir = Join-Path $env:APPDATA 'mcode-island' if (!(Test-Path $cfgDir)) { New-Item -ItemType Directory -Path $cfgDir -Force | Out-Null } $cfgFile = Join-Path $cfgDir 'config.json' +. "$PSScriptRoot/scripts/lib/Protect-Token.ps1" + function Read-Cfg { if (Test-Path $cfgFile) { - try { return (Get-Content $cfgFile -Raw -Encoding UTF8 | ConvertFrom-Json) } catch {} + try { return ([System.IO.File]::ReadAllText($cfgFile) | ConvertFrom-Json) } catch {} } return [PSCustomObject]@{} } function Write-Cfg($obj) { - $obj | ConvertTo-Json | Out-File -FilePath $cfgFile -Encoding UTF8 + # 原子替换:config.json 里存着 token,写到一半失败会把 token 弄丢。 + Write-AtomicText -Path $cfgFile -Content ($obj | ConvertTo-Json -Depth 10) +} + +function Set-CfgProp($cfg, [string]$name, $value) { + if ($cfg.PSObject.Properties[$name]) { $cfg.$name = $value } + else { $cfg | Add-Member -NotePropertyName $name -NotePropertyValue $value } } if ($Show) { $cfg = Read-Cfg - $hasCfg = $cfg.PSObject.Properties['planApiToken'] -and $cfg.planApiToken + $hasEnc = $cfg.PSObject.Properties['planApiTokenEnc'] -and $cfg.planApiTokenEnc + $hasLegacy = $cfg.PSObject.Properties['planApiToken'] -and $cfg.planApiToken + $envAllowed = $cfg.PSObject.Properties['allowEnvToken'] -and $cfg.allowEnvToken $hasEnv = $env:MINIMAX_OAUTH_TOKEN -or $env:MINIMAX_API_KEY - if ($hasEnv) { + + if ($hasEnc) { + $plain = Unprotect-Token ([string]$cfg.planApiTokenEnc) + $masked = if ($plain) { $plain.Substring(0, [Math]::Min(10, $plain.Length)) + '...' } else { '(无法解密)' } + Write-Output "token 来源: config.json planApiTokenEnc (DPAPI 加密, $masked)" + } elseif ($hasLegacy) { + Write-Output "token 来源: config.json planApiToken (旧版明文,下次启动 detector 时自动迁移成密文)" + } elseif ($envAllowed -and $hasEnv) { $src = if ($env:MINIMAX_OAUTH_TOKEN) { 'env:MINIMAX_OAUTH_TOKEN' } else { 'env:MINIMAX_API_KEY' } - Write-Output "token 来源: $src" - } elseif ($hasCfg) { - $masked = $cfg.planApiToken.Substring(0, [Math]::Min(10, $cfg.planApiToken.Length)) + '...' - Write-Output "token 来源: config.json planApiToken ($masked)" + Write-Output "token 来源: $src (已通过 -AllowEnvToken 授权)" + } elseif ($envAllowed) { + Write-Output "token 来源: 未配置(已允许读环境变量,但环境里没有 MINIMAX_OAUTH_TOKEN / MINIMAX_API_KEY)" + } elseif ($hasEnv) { + Write-Output "token 未配置(环境里存在 MINIMAX_OAUTH_TOKEN / MINIMAX_API_KEY,但没有用 -AllowEnvToken 授权,所以不读)" } else { Write-Output "token 未配置" } @@ -53,29 +81,55 @@ if ($Show) { if ($Clear) { $cfg = Read-Cfg - if ($cfg.PSObject.Properties['planApiToken']) { - $cfg.PSObject.Properties.Remove('planApiToken') + $changed = $false + foreach ($name in @('planApiTokenEnc', 'planApiToken')) { + if ($cfg.PSObject.Properties[$name]) { $cfg.PSObject.Properties.Remove($name); $changed = $true } + } + if ($changed) { Write-Cfg $cfg - Write-Output '已从 config.json 删除 planApiToken' + Write-Output '已从 config.json 删除 token' + } else { + Write-Output 'config.json 没有 token,无需删除' + } + exit 0 +} + +if ($AllowEnvToken -or $NoEnvToken) { + $cfg = Read-Cfg + Set-CfgProp $cfg 'allowEnvToken' ([bool]$AllowEnvToken) + Write-Cfg $cfg + if ($AllowEnvToken) { + Write-Output '已允许从环境变量 MINIMAX_OAUTH_TOKEN / MINIMAX_API_KEY 读取 token' } else { - Write-Output 'config.json 没有 planApiToken,无需删除' + Write-Output '已禁止从环境变量读取 token' } + Write-Output '重启 detector 后生效: mcode-island detect-off && mcode-island detect-on' exit 0 } if (-not $Token) { - Write-Output '用法: set-token.ps1 | -Show | -Clear' + Write-Output '用法: set-token.ps1 | -Show | -Clear | -AllowEnvToken | -NoEnvToken' exit 1 } -$cfg = Read-Cfg -if ($cfg.PSObject.Properties['planApiToken']) { - $cfg.planApiToken = $Token -} else { - $cfg | Add-Member -NotePropertyName 'planApiToken' -NotePropertyValue $Token +if (-not (Test-TokenProtection)) { + Write-Output '这台机器上的 PowerShell 没有可用的 DPAPI,token 只能以明文存放。' + Write-Output '为避免明文落盘,这里不做任何写入。' + exit 1 } + +$enc = Protect-Token $Token +if (-not $enc) { + Write-Output 'DPAPI 加密失败,没有写入任何内容。' + exit 1 +} + +$cfg = Read-Cfg +# 旧版明文字段顺手清掉 +if ($cfg.PSObject.Properties['planApiToken']) { $cfg.PSObject.Properties.Remove('planApiToken') } +Set-CfgProp $cfg 'planApiTokenEnc' $enc Write-Cfg $cfg $masked = $Token.Substring(0, [Math]::Min(10, $Token.Length)) + '...' Write-Output "已写入 $cfgFile" -Write-Output " planApiToken: $masked" +Write-Output " planApiTokenEnc: $masked (DPAPI 加密)" Write-Output '重启 detector 后生效: mcode-island detect-off && mcode-island detect-on' From fbdfd53bf76bd11153ae39945480add76f63f7bb Mon Sep 17 00:00:00 2001 From: antianqi Date: Thu, 22 Oct 2026 23:46:44 +0800 Subject: [PATCH 31/38] feat(mcode-island): read the plan usage from the runtime instead of asking for a credential The 5h usage readout does not have to be a credentialed feature. The MiniMax Code runtime already resolves the token plan with the user's own login -- the mcode launcher's "MCode status" panel renders it, with the footer "Local view - credentials hidden" -- and it keeps the numbers in memory. The plugin can read the same numbers from a local file the runtime writes, and then it never holds a credential and never makes a request of its own. scripts/lib/Plan-Usage.ps1 reads that file. The contract is the runtime's own field names, so the runtime can dump what it already has: { "fetchedAtMs": 1792800000000, "fiveHour": { "remainingPercent": 58, "resetAtMs": 1792800000000, "unlimited": false }, "weekly": { "remainingPercent": 40, "resetAtMs": 1792900000000, "unlimited": false }, "video": { "remainingCount": 12, "totalCount": 40 } } The canonical path is %APPDATA%\mcode-island\usage.json; the runtime's own data directories are read as a fallback, and MCODE_PLAN_USAGE_FILE overrides everything. The detector now prefers that file and only falls back to the credentialed query when the file is absent. The fallback is the existing scripts/lib/Get-5hUsage.ps1 path, which stays as it is: the token is DPAPI-encrypted, the environment token needs an explicit opt-in, and the request no longer claims to be a MiniMax-owned surface. A build that does not ship Get-5hUsage.ps1 -- the Marketplace package -- therefore has no credential path at all, and the readout keeps working from the runtime's file alone. Validation * with the token removed from config.json, the detector still reports usage5h=58 and a reset countdown in ~60 minutes, read from usage.json * without usage.json the detector falls back to the credentialed query * the Marketplace package drops the fallback line and ships scripts/lib/Plan-Usage.ps1 instead, so the readout there is credential-free Design compliance * the plugin reads local data the runtime produced; it does not request, store or transmit any credential * the credentialed path remains available for the hosted plugin, where the user opted in explicitly * the two paths share the same output fields, so the widget does not care which one produced the numbers --- plugins/antianqi/mcode-island/README.md | 30 ++++++- .../mcode-island/mcode-status-detect.ps1 | 7 +- .../mcode-island/scripts/lib/Plan-Usage.ps1 | 88 +++++++++++++++++++ 3 files changed, 121 insertions(+), 4 deletions(-) create mode 100644 plugins/antianqi/mcode-island/scripts/lib/Plan-Usage.ps1 diff --git a/plugins/antianqi/mcode-island/README.md b/plugins/antianqi/mcode-island/README.md index 09c6afb9..8025cdd5 100644 --- a/plugins/antianqi/mcode-island/README.md +++ b/plugins/antianqi/mcode-island/README.md @@ -278,9 +278,33 @@ and never reads or writes anything outside `%APPDATA%\mcode-island\`. ## Network access -The widget is fully offline. The only network caller in this plugin is -`mcode-status-detect.ps1` (Mode B detector), and it only makes a request -when ALL of the following are true: +**The 5h usage readout does not need the network.** The MiniMax Code +runtime already resolves the token plan with the user's own login (the +mcode launcher's "MCode status" panel renders it, footer: "Local view - +credentials hidden"). When the runtime writes that result to a local file, +the plugin reads it and never touches a credential. + +``` +%APPDATA%\mcode-island\usage.json +``` + +```json +{ + "fetchedAtMs": 1792800000000, + "fiveHour": { "remainingPercent": 58, "resetAtMs": 1792800000000, "unlimited": false }, + "weekly": { "remainingPercent": 40, "resetAtMs": 1792900000000, "unlimited": false }, + "video": { "remainingCount": 12, "totalCount": 40 } +} +``` + +`scripts/lib/Plan-Usage.ps1` reads it. `MCODE_PLAN_USAGE_FILE` overrides +the path; `~/.minimax/usage.json` is read as a fallback. `fiveHour` is +required, `weekly` and `video` are optional. + +When that file is absent, the plugin falls back to asking the API itself, +and the only network caller in this plugin is `mcode-status-detect.ps1` +(Mode B detector). It only makes a request when ALL of the following are +true: 1. A token is configured — either `set-token.ps1 `, which encrypts it into `config.json:planApiTokenEnc`, or an environment variable diff --git a/plugins/antianqi/mcode-island/mcode-status-detect.ps1 b/plugins/antianqi/mcode-island/mcode-status-detect.ps1 index e699886f..b370e414 100644 --- a/plugins/antianqi/mcode-island/mcode-status-detect.ps1 +++ b/plugins/antianqi/mcode-island/mcode-status-detect.ps1 @@ -167,6 +167,8 @@ if (Test-Path "$PSScriptRoot/scripts/lib/Get-5hUsage.ps1") { # 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" +# 运行时落盘的套餐用量(零凭据:只读本地文件,自己不联网) +. "$PSScriptRoot/scripts/lib/Plan-Usage.ps1" # Cross-process status.json protocol (round-21 review #1). # # The detector and the agent hooks are two independent processes that @@ -932,7 +934,10 @@ try { # 4) 5h 用量:每 60s 刷一次,刷新后若数字变化就写 status.json(让 widget 看到) $prevPct = $script:plan5hRemainingPct $prevMs = $script:plan5hResetMs - if ($script:plan5hAvailable) { Refresh-5hUsage } + # 优先读运行时落盘的用量(零凭据:只读本地文件,自己不联网) + Get-PlanUsage | Out-Null + # 读不到才回落到自实现查询(需要凭据;上架包不发布那个模块,这一行会被剥掉) + if (-not $script:plan5hFromRuntime -and $script:plan5hAvailable) { Refresh-5hUsage } $curPct = $script:plan5hRemainingPct $curMs = $script:plan5hResetMs $usageChanged = ($prevPct -ne $curPct) -or ($prevMs -ne $curMs) -and ($null -ne $curPct) diff --git a/plugins/antianqi/mcode-island/scripts/lib/Plan-Usage.ps1 b/plugins/antianqi/mcode-island/scripts/lib/Plan-Usage.ps1 new file mode 100644 index 00000000..6120e2bd --- /dev/null +++ b/plugins/antianqi/mcode-island/scripts/lib/Plan-Usage.ps1 @@ -0,0 +1,88 @@ +# scripts/lib/Plan-Usage.ps1 +# +# Reads the plan usage that the MiniMax Code runtime already fetched. +# +# The runtime resolves the token plan with the user's own login (the +# "MCode status" panel in the mcode launcher shows it, footer: "Local view +# - credentials hidden") and keeps the numbers in memory. This lib reads the +# same numbers from a local file the runtime writes, so this plugin never +# holds a credential and never makes a request of its own. +# +# # Contract +# +# One JSON object, the same field names the runtime already uses for its +# status panel: +# +# { +# "fetchedAtMs": 1792800000000, +# "fiveHour": { "remainingPercent": 58, "resetAtMs": 1792800000000, "unlimited": false }, +# "weekly": { "remainingPercent": 40, "resetAtMs": 1792900000000, "unlimited": false }, +# "video": { "remainingCount": 12, "totalCount": 40 } +# } +# +# Only "fiveHour" is required; "weekly" and "video" are optional. +# +# # Where the file lives +# +# The canonical path is %APPDATA%\mcode-island\usage.json. The other +# candidates below are read as a fallback so the plugin keeps working if the +# runtime writes to its own data directory instead. MCODE_PLAN_USAGE_FILE +# overrides everything. +# +# # Behaviour +# +# Sets $script:plan5hRemainingPct (0..100, or $null when unknown) and +# $script:plan5hResetMs (milliseconds until the window resets, relative). +# Returns $true when a usable file was read. + +$script:planUsageCandidates = @( + $env:MCODE_PLAN_USAGE_FILE + (Join-Path $env:APPDATA 'mcode-island\usage.json') + (Join-Path $env:USERPROFILE '.minimax\usage.json') +) + +function Get-PlanUsage { + $script:plan5hRemainingPct = $null + $script:plan5hResetMs = $null + $script:plan5hFromRuntime = $false + + foreach ($candidate in $script:planUsageCandidates) { + if (-not $candidate) { continue } + if (-not (Test-Path $candidate)) { continue } + try { + $raw = [System.IO.File]::ReadAllText($candidate) + $data = $raw | ConvertFrom-Json + } catch { + continue + } + if (-not $data) { continue } + + $five = $null + if ($data.PSObject.Properties['fiveHour']) { $five = $data.fiveHour } + if (-not $five) { continue } + + $pct = $null + if ($five.PSObject.Properties['unlimited'] -and $five.unlimited) { + # Unlimited means there is no percentage to show. -2 is the widget's + # "not provided" value, so the badge keeps its time part only. + $pct = -2 + } elseif ($five.PSObject.Properties['remainingPercent']) { + $pct = [int]$five.remainingPercent + if ($pct -lt 0) { $pct = 0 } + if ($pct -gt 100) { $pct = 100 } + } + + $resetMs = $null + if ($five.PSObject.Properties['resetAtMs']) { + $delta = [long]$five.resetAtMs - [long]([DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()) + if ($delta -gt 0) { $resetMs = $delta } + } + + $script:plan5hRemainingPct = $pct + $script:plan5hResetMs = $resetMs + $script:plan5hFromRuntime = $true + return $true + } + + return $false +} From 0d78a4bc67c1c030658679b5d9397b5459482a8b Mon Sep 17 00:00:00 2001 From: antianqi Date: Fri, 23 Oct 2026 00:21:27 +0800 Subject: [PATCH 32/38] feat(mcode-island): the click brings the user back to the desktop app window In the desktop app the agent is hosted by the app and runs in a terminal the app opened. Hiding that terminal does not stop the agent: the user is left looking at a static pill while the work continues behind it. The action that matches the intent is "take me back to the app window", so the click target is now resolved on every click, in this order: 1. a manual pin (pin.json) -> focus that window 2. the desktop app main window -> focus it, never hide it 3. the caller's terminal (caller.json) -> the original show/hide toggle The desktop app is found by process name ("MiniMax Inside Code", "MiniMax Code", "Mavis") inside the widget's own session: the app is not an ancestor of the agent process, so the process tree cannot be used to locate it. The pin moves from caller.json into its own pin.json. caller.json is rewritten on every status transition, so a pin stored there did not survive the next notify -- with the pin in the way, "the pin beats the desktop app" would have been unreachable in practice. `mcode-island unpin` now clears both files. Validation ----------- * Windows PowerShell 5.1 parser: 0 errors on mcode-island.ps1 and pin-island.ps1. * The click-target resolver was exercised against the live desktop app: with MiniMax Inside Code running the click resolves to the app window (mode=focus); with a pin.json present it resolves to the pinned window (mode=focus); with neither it falls back to the caller's terminal (mode=toggle). * The live focus path was executed against the running desktop app: the click resolved to "MiniMax Inside Code" (mode=focus, origin=desktop-app), the app window came to the foreground, and it stayed visible. * Negative injection: 8 deliberate mutations of this contract (priority inverted, focus path hiding the target, pin reading caller.json, click handler bypassing the dispatcher, dispatcher ignoring the Mode, cross-session app lookup, pin writer writing caller.json, unpin not clearing pin.json) each trip at least one drift lock; the unmutated source passes 146/146. Test evidence ------------- * node scripts/smoke.mjs -> 146 pass, 7 warn, 0 fail (was 131). * node scripts/test-substep-progress.mjs -> 116 pass, 0 fail. * pwsh -File scripts/test-windows-workflow-local.ps1 -> all 5 steps OK. * Windows PowerShell 5.1 parser: 0 errors on both changed scripts. Design compliance ----------------- * The behaviour lives in the plugin source, not in the Marketplace package builder, so the CLI build and the Marketplace build share one implementation, and the CLI keeps its original behaviour when no desktop app is running. * No new credential, no new network call, no new file outside %APPDATA%\mcode-island. * The focus path never calls ShowWindow(SW_HIDE); only the terminal toggle does. --- plugins/antianqi/mcode-island/README.md | 49 +++- .../antianqi/mcode-island/mcode-island.cmd | 2 +- .../antianqi/mcode-island/mcode-island.ps1 | 212 ++++++++++++++--- plugins/antianqi/mcode-island/pin-island.ps1 | 12 +- .../antianqi/mcode-island/scripts/smoke.mjs | 222 +++++++++++++++--- .../scripts/test-substep-progress.mjs | 4 +- plugins/antianqi/mcode-island/skills/SKILL.md | 32 ++- .../mcode-island/skills/mcode-island/SKILL.md | 32 ++- 8 files changed, 455 insertions(+), 110 deletions(-) diff --git a/plugins/antianqi/mcode-island/README.md b/plugins/antianqi/mcode-island/README.md index 8025cdd5..83a42e45 100644 --- a/plugins/antianqi/mcode-island/README.md +++ b/plugins/antianqi/mcode-island/README.md @@ -11,7 +11,9 @@ the terminal in the background and still see exactly what your agent is doing. |:----:|:--------:|:-------:|:-------:|:----:|:-----:| | ![idle](assets/state-idle.png) | ![thinking](assets/state-thinking.png) | ![working](assets/state-working.png) | ![waiting](assets/state-waiting.png) | ![done](assets/state-done.png) | ![error](assets/state-error.png) | -The pill is a single 320×60 WPF window, always-on-top, click-to-focus. The +The pill is a single 320×60 WPF window, always-on-top, click-to-focus: the click goes +back to the desktop app window, to a manually pinned window, or to the +originating terminal. The widget polls `%APPDATA%\mcode-island\status.json` every 400 ms; the agent (or a thin wrapper) just writes JSON to that file. @@ -216,7 +218,7 @@ mcode-island/ ├── stop-island.ps1 # stop the widget ├── status-island.ps1 # print widget PID + recent log ├── show-island.ps1 # re-raise hidden widget -├── pin-island.ps1 # lock click-to-focus target +├── pin-island.ps1 # pin the click target to the foreground window ├── autostart.ps1 # register / unregister Windows logon ├── notify-island.ps1 # state-push helper (Mode B) ├── wrap-tool.ps1 # all-in-one bash wrapper @@ -263,7 +265,8 @@ binary, no symlink, no `node_modules`. | data | location | lifetime | purpose | | ---------------- | ------------------------------------- | ------------------------- | ------------------------------------ | | `status.json` | `%APPDATA%\mcode-island\` | rewritten every transition | widget polling | -| `caller.json` | `%APPDATA%\mcode-island\` | rewritten every transition | click-to-focus target HWND | +| `caller.json` | `%APPDATA%\mcode-island\` | rewritten every transition | originating terminal HWND (click fallback) | +| `pin.json` | `%APPDATA%\mcode-island\` | until `mcode-island unpin` | manually pinned click target | | `config.json` | `%APPDATA%\mcode-island\` | rewritten on drag | pill position, size, opacity | | `config.json` -> `planApiTokenEnc` | `%APPDATA%\mcode-island\` | until `-Clear` or manual edit | 5h usage API token, DPAPI-encrypted (opt-in; see "Accounts") | | `widget.pid` | `%APPDATA%\mcode-island\` | rewritten on start | widget process PID | @@ -365,8 +368,8 @@ mcode-island REM start the widget (no-op if already running) mcode-island stop REM stop the widget mcode-island status REM show widget PID, session, last 5 log lines mcode-island show REM re-raise a hidden widget -mcode-island pin REM lock click-to-focus target to current foreground -mcode-island unpin REM clear focus target +mcode-island pin REM pin the click target to the current foreground window +mcode-island unpin REM clear the pinned click target mcode-island autostart-on REM register for Windows logon mcode-island autostart-off REM unregister ``` @@ -377,15 +380,30 @@ invoke `.ps1` more easily than `.cmd`. ## How click-to-focus works -When the agent calls `notify-island.ps1`, the helper probes the process chain -for an attached console window, and falls back to the first ancestor with a -`MainWindowHandle`. The discovered HWND / PID is written to `caller.json`. When -the user clicks the pill, the widget uses `SetForegroundWindow` on that HWND. +The click target is resolved on every click, in this order: -If the auto-detected HWND is wrong (common on Windows Terminal with multiple -tabs), run `mcode-island pin` from inside the tab you want to return to. The -CLI grabs the foreground window *before* the widget steals focus, so this is -the most reliable fix. +| # | target | how it is found | what the click does | +| - | ------------------- | -------------------------------------- | -------------------------------- | +| 1 | a manual pin | `pin.json` (written by `mcode-island pin`) | focuses that window | +| 2 | the desktop app | a process named `MiniMax Inside Code` / `MiniMax Code` / `Mavis` in the widget's own session | focuses the app window | +| 3 | the caller's terminal | `caller.json` (written by `notify-island.ps1`) | shows it, or hides it if it is already visible | + +**Why the desktop app wins over the terminal.** In the desktop app the agent +is hosted by the app and runs in a terminal the app opened. Hiding that +terminal does not stop the agent — it just leaves the user looking at a static +pill. "Take me back to the app window" is the action that matches the +intent, so the app window is focused and never hidden. + +**Why the pin still wins.** `mcode-island pin` stores the foreground window in +`pin.json`, a separate file from `caller.json`: `caller.json` is rewritten on +every status transition, so a pin stored there would not survive the next +push. The pin is the escape hatch for the case where the desktop app is open +next to a CLI session and the user wants the click to go to their terminal. + +When the auto-detected HWND is wrong (common on Windows Terminal with +multiple tabs), run `mcode-island pin` from inside the tab you want to return +to. The CLI grabs the foreground window *before* the widget steals focus, so +this is the most reliable fix. `mcode-island unpin` clears it. The widget is also intentionally hard to kill. Alt+F4 sends `WM_CLOSE`, which the widget intercepts in `WndProc` and treats as Hide rather than Close. To @@ -404,6 +422,11 @@ a live MiniMax Code session. Empirical evidence (captured during development): `waiting` → `working` → `done` on a real `bash npm test` run. - Click-to-focus round-trip verified: from a Feishu tab, click the pill, focus jumps to the originating Windows Terminal tab (HWND consistent). +- Click-target order verified against the live desktop app: with + `MiniMax Inside Code` running, the click resolves to the app window + (`mode=focus`, never hidden); with a `pin.json` present it resolves to the + pinned window; with neither, it falls back to the caller's terminal with + the original show/hide toggle (`mode=toggle`). - `wrap-tool.ps1` exit-code semantics: 0 → `done`, 1 → `waiting` (default; configurable via `-WaitingExitCodes`), other → `error`. As of v0.2.1 the wrapper no longer executes the command itself; the agent runs the command diff --git a/plugins/antianqi/mcode-island/mcode-island.cmd b/plugins/antianqi/mcode-island/mcode-island.cmd index 2720cda7..a33ba6e9 100644 --- a/plugins/antianqi/mcode-island/mcode-island.cmd +++ b/plugins/antianqi/mcode-island/mcode-island.cmd @@ -47,7 +47,7 @@ exit /b 0 exit /b %errorlevel% :unpin -%PS% -Command "Remove-Item -Path (Join-Path $env:APPDATA 'mcode-island\caller.json') -ErrorAction SilentlyContinue; Write-Output 'unpinned: caller.json cleared'" +%PS% -Command "Remove-Item -Path (Join-Path $env:APPDATA 'mcode-island\pin.json'),(Join-Path $env:APPDATA 'mcode-island\caller.json') -ErrorAction SilentlyContinue; Write-Output 'unpinned: pin.json and caller.json cleared'" exit /b %errorlevel% :autostart_on diff --git a/plugins/antianqi/mcode-island/mcode-island.ps1 b/plugins/antianqi/mcode-island/mcode-island.ps1 index 4caff875..7ea72862 100644 --- a/plugins/antianqi/mcode-island/mcode-island.ps1 +++ b/plugins/antianqi/mcode-island/mcode-island.ps1 @@ -631,11 +631,13 @@ function Resolve-CallerWindow { $hwnd = [IntPtr]::Zero $targetPid = 0 $targetExe = '' + $targetSource = '' try { $data = Get-Content $callerFile -Raw -Encoding UTF8 | ConvertFrom-Json $hwnd = [IntPtr]::new([int64]$data.targetHwnd) $targetPid = [int]$data.targetPid $targetExe = if ($data.targetExe) { [string]$data.targetExe } else { '' } + $targetSource = if ($data.source) { [string]$data.source } else { '' } } catch { Dbg "RESOLVE: caller.json parse error" return $null @@ -658,6 +660,7 @@ function Resolve-CallerWindow { if ($parentProc) { $targetPid = $parentProc.Id $targetExe = $parentProc.ProcessName + $targetSource = 'parent-fallback' if ($parentProc.MainWindowHandle -ne [IntPtr]::Zero) { $hwnd = $parentProc.MainWindowHandle } else { @@ -683,33 +686,149 @@ function Resolve-CallerWindow { Dbg "RESOLVE: re-resolved to hwnd $hwnd" } + return [PSCustomObject]@{ Hwnd = $hwnd; Pid = $targetPid; Exe = $targetExe; Source = $targetSource } +} + +# --------------------------------------------------------------------------- +# Click target resolution. +# +# In the desktop app (MiniMax Inside Code / MiniMax Code / Mavis) the agent +# is hosted by the app and runs in a terminal the app opened. That terminal +# belongs to the app session, so "hide the terminal" is meaningless there: +# the agent keeps running behind a static pill. "Take me back to the app +# window" is the action that matches the intent. +# +# The click target is therefore resolved in this order: +# 1. a manual pin (pin.json) -- the user named the target explicitly, so it +# wins. A CLI user pins their terminal and it stays the target even +# while the desktop app is open. +# 2. the desktop app main window -- found through a process-name allowlist +# and focused, never hidden. +# 3. the caller's terminal (caller.json) -- without the desktop app the +# original show/hide toggle is kept. +# --------------------------------------------------------------------------- + +# Process-name allowlist of the desktop app. The app is not an ancestor of +# the agent process (the agent runs in a terminal the app opened), so the +# process tree cannot be used to locate it. +$script:desktopAppNames = @('MiniMax Inside Code', 'MiniMax Code', 'Mavis') + +# Read pin.json: the click target the user pinned by hand. +# pin.json is separate from caller.json -- caller.json is rewritten by the +# agent side on every state transition, so a pin stored there would not +# survive the next notify and priority 1 would be unreachable. +function Resolve-PinnedWindow { + $pinFile = Join-Path $env:APPDATA 'mcode-island\pin.json' + if (!(Test-Path $pinFile)) { return $null } + + $hwnd = [IntPtr]::Zero + $targetPid = 0 + $targetExe = '' + try { + $data = Get-Content $pinFile -Raw -Encoding UTF8 | ConvertFrom-Json + $hwnd = [IntPtr]::new([int64]$data.targetHwnd) + $targetPid = [int]$data.targetPid + $targetExe = if ($data.targetExe) { [string]$data.targetExe } else { '' } + } catch { + Dbg 'PIN: pin.json parse error' + return $null + } + if ($targetPid -le 0) { return $null } + + # the hwnd died (window destroyed or recreated) -> find it again by pid + if ($hwnd -eq [IntPtr]::Zero -or -not [WinAPI]::IsWindow($hwnd)) { + $hwnd = [WinAPI]::FindVisibleWindowForPid([uint32]$targetPid) + } + if ($hwnd -eq [IntPtr]::Zero) { + Dbg "PIN: pinned PID $targetPid is gone" + return $null + } 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 } +# Find the desktop app main window: a process in the same session whose name +# hits the allowlist and that owns a main window. The app is an Electron +# multi-process (main + renderer + GPU + utility); only the main process +# reports a non-zero MainWindowHandle. +function Find-DesktopAppWindow { + $mySession = (Get-Process -Id $PID).SessionId + $cands = @(Get-Process -ErrorAction SilentlyContinue | Where-Object { + $script:desktopAppNames -contains $_.ProcessName -and $_.SessionId -eq $mySession + }) + if ($cands.Count -eq 0) { Dbg 'APP: no desktop app process'; return $null } + + foreach ($p in $cands) { + if ($p.MainWindowHandle -ne [IntPtr]::Zero) { + Dbg "APP: desktop app $($p.ProcessName) PID=$($p.Id) hwnd=$($p.MainWindowHandle)" + return [PSCustomObject]@{ Hwnd = $p.MainWindowHandle; Pid = $p.Id; Exe = $p.ProcessName } + } + } + # The main process reports no MainWindowHandle (some Electron builds) -> + # enumerate the visible windows as a fallback. + foreach ($p in $cands) { + $h = [WinAPI]::FindVisibleWindowForPid([uint32]$p.Id) + if ($h -ne [IntPtr]::Zero) { + Dbg "APP: desktop app $($p.ProcessName) PID=$($p.Id) hwnd=$h (enumerated)" + return [PSCustomObject]@{ Hwnd = $h; Pid = $p.Id; Exe = $p.ProcessName } + } + } + Dbg 'APP: desktop app process has no visible window' + return $null +} + +# Resolve the click target. Mode = focus (raise and activate only) / +# toggle (show / hide). +function Resolve-ClickTarget { + # 1) the manual pin + $pin = Resolve-PinnedWindow + if ($pin) { + return [PSCustomObject]@{ Hwnd = $pin.Hwnd; Pid = $pin.Pid; Exe = $pin.Exe; Mode = 'focus'; Origin = 'pin' } + } + # 2) the desktop app main window + $app = Find-DesktopAppWindow + if ($app) { + return [PSCustomObject]@{ Hwnd = $app.Hwnd; Pid = $app.Pid; Exe = $app.Exe; Mode = 'focus'; Origin = 'desktop-app' } + } + # 3) the caller's terminal (old behaviour: show / hide) + $caller = Resolve-CallerWindow + if ($caller) { + return [PSCustomObject]@{ Hwnd = $caller.Hwnd; Pid = $caller.Pid; Exe = $caller.Exe; Mode = 'toggle'; Origin = 'caller' } + } + return $null +} + +# Raise a window to the front: show + focus, whatever its current visible +# state is. After the desktop-app adaptation both focus and toggle take a +# resolved target object; the caller decides which one it is. +function Focus-Window { + param([Parameter(Mandatory = $true)]$Target) try { - [WinAPI]::AllowSetForegroundWindow([uint32]$r.Pid) | Out-Null - if ([WinAPI]::IsIconic($r.Hwnd)) { - [WinAPI]::ShowWindow($r.Hwnd, 9) | Out-Null # SW_RESTORE + [WinAPI]::AllowSetForegroundWindow([uint32]$Target.Pid) | Out-Null + if ([WinAPI]::IsIconic($Target.Hwnd)) { + [WinAPI]::ShowWindow($Target.Hwnd, 9) | Out-Null # SW_RESTORE + } else { + [WinAPI]::ShowWindow($Target.Hwnd, 5) | Out-Null # SW_SHOW } - [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) + # TOPMOST -> NOTOPMOST: it lets BringWindowToTop push the window all the + # way up without leaving a "always on top" side effect behind. + [WinAPI]::SetWindowPos($Target.Hwnd, [WinAPI]::HWND_TOPMOST, 0, 0, 0, 0, [WinAPI]::SWP_NOACTIVATE) | Out-Null + [WinAPI]::SetWindowPos($Target.Hwnd, [IntPtr]::new(-2), 0, 0, 0, 0, [WinAPI]::SWP_NOACTIVATE) | Out-Null # HWND_NOTOPMOST + [WinAPI]::BringWindowToTop($Target.Hwnd) | Out-Null + [WinAPI]::SetForegroundWindow($Target.Hwnd) | Out-Null + Dbg ("FOCUS OK: target=" + $Target.Exe + " PID=" + $Target.Pid + " hwnd=" + $Target.Hwnd) } catch { Dbg "FOCUS FAIL: $($_.Exception.Message)" } } +# 兼容旧调用点:自动聚焦流程(needs_input 状态自动弹窗那种)用调用方窗口。 +function Focus-CallerWindow { + $r = Resolve-CallerWindow + if (-not $r) { return } + Focus-Window $r +} + # 单击 pill toggle:可见 → 隐藏;隐藏 → 全屏还原 + 抢焦点。 # 设计取舍 (round-14+15+16): # hide 分支用 SW_HIDE (而不是 SW_MINIMIZE): @@ -725,25 +844,27 @@ function Focus-CallerWindow { # 状态判定: 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 } +function Toggle-Window { + param([Parameter(Mandatory = $true)]$Target) try { - $isShown = [WinAPI]::IsWindowVisible($r.Hwnd) + $isShown = [WinAPI]::IsWindowVisible($Target.Hwnd) if ($isShown) { - [WinAPI]::ShowWindow($r.Hwnd, 0) | Out-Null # SW_HIDE - Dbg "TOGGLE: hid target=$($r.Exe) PID=$($r.Pid) hwnd=$($r.Hwnd)" + [WinAPI]::ShowWindow($Target.Hwnd, 0) | Out-Null # SW_HIDE + Dbg "TOGGLE: hid target=$($Target.Exe) PID=$($Target.Pid) hwnd=$($Target.Hwnd)" } else { # 1) SW_MAXIMIZE 激活+标记 maximized - [WinAPI]::ShowWindow($r.Hwnd, 3) | Out-Null # SW_MAXIMIZE + [WinAPI]::ShowWindow($Target.Hwnd, 3) | Out-Null # SW_MAXIMIZE # 2) SetWindowPos 强制拉到 monitor work area (绕过 DPI/remembered-size 限制) - $wa = [WinAPI]::GetWorkAreaForWindow($r.Hwnd) + $wa = [WinAPI]::GetWorkAreaForWindow($Target.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 + [WinAPI]::SetWindowPos($Target.Hwnd, [IntPtr]::Zero, $wa.Left, $wa.Top, $cx, $cy, [WinAPI]::SWP_NOZORDER) | Out-Null + # The -f has to sit inside the parentheses: written as + # `Dbg "..." -f a, b` the format operator binds to the command, not to + # the string, and the log line keeps the raw {0} {1} placeholders. + Dbg ("TOGGLE: forced to work area ({0},{1}) {2}x{3}" -f $wa.Left, $wa.Top, $cx, $cy) } # 3) 抢 z-order 到最前(SetWindowPos(HWND_TOP) 不需要 foreground 权限) # SWP_NOSIZE:cx=0/cy=0 缺它会被 Windows 当成"resize 到 0x0", @@ -752,18 +873,39 @@ function Toggle-CallerWindow { # 但副屏(monitor 原点非 0)上会把还原后的窗口甩到主屏左上角。 # 同样不能加 SWP_NOZORDER——那个 flag 会让 hWndInsertAfter 被忽略, # HWND_TOP 就白传了,BringWindowToTop 之后仍可能被别的窗口盖住。 - [WinAPI]::AllowSetForegroundWindow([uint32]$r.Pid) | Out-Null + [WinAPI]::AllowSetForegroundWindow([uint32]$Target.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)" + [WinAPI]::SetWindowPos($Target.Hwnd, [WinAPI]::HWND_TOP, 0, 0, 0, 0, $nofollow) | Out-Null + [WinAPI]::BringWindowToTop($Target.Hwnd) | Out-Null + [WinAPI]::SetForegroundWindow($Target.Hwnd) | Out-Null + Dbg "TOGGLE: shown (maximized + work-area) target=$($Target.Exe) PID=$($Target.Pid) hwnd=$($Target.Hwnd)" } } catch { Dbg "TOGGLE FAIL: $($_.Exception.Message)" } } +# 兼容旧调用点。 +function Toggle-CallerWindow { + $r = Resolve-CallerWindow + if (-not $r) { return } + Toggle-Window $r +} + +# The single entry point for a pill click: resolve the target, then run the +# mode it resolved to. focus (manual pin / desktop app) and toggle (the +# caller's terminal) are two different semantics -- do not mix them. +function Invoke-ClickTarget { + $t = Resolve-ClickTarget + if (-not $t) { Dbg 'CLICK: no target resolved'; return } + Dbg "CLICK: target=$($t.Exe) PID=$($t.Pid) hwnd=$($t.Hwnd) mode=$($t.Mode) origin=$($t.Origin)" + if ($t.Mode -eq 'focus') { + Focus-Window $t + } else { + Toggle-Window $t + } +} + # 手动设置焦点目标(右键菜单调用):把当前前台窗口记为 focus target function Set-FocusTarget-Current { Add-Type @" @@ -782,7 +924,9 @@ public class FG2 { if (-not $proc) { Dbg 'PIN: target process gone'; return } $myPid = [int](Get-Process -Id $PID).Id $mySession = (Get-Process -Id $myPid).SessionId - $callerFile = Join-Path $env:APPDATA 'mcode-island\caller.json' + # Write pin.json, not caller.json: a pin is the user's explicit choice and + # must not be wiped by the agent-side caller.json on every transition. + $pinFile = Join-Path $env:APPDATA 'mcode-island\pin.json' $payload = [PSCustomObject]@{ callerPid = $myPid callerSession = $mySession @@ -793,7 +937,7 @@ public class FG2 { source = 'manual-pin' ts = (Get-Date).ToString('o') } | ConvertTo-Json -Compress - [System.IO.File]::WriteAllText($callerFile, $payload, [System.Text.Encoding]::UTF8) + [System.IO.File]::WriteAllText($pinFile, $payload, [System.Text.Encoding]::UTF8) Dbg ("PIN: target=" + $proc.ProcessName + " PID=" + $proc.Id + " hwnd=" + $hwnd) } @@ -969,7 +1113,7 @@ $window.Add_MouseLeftButtonUp({ if ($script:dragStart -and -not $script:didDrag) { Dbg 'CLICK detected' Flash-Click - Toggle-CallerWindow + Invoke-ClickTarget } } catch { Dbg "CLICK FAIL: $($_.Exception.Message)" diff --git a/plugins/antianqi/mcode-island/pin-island.ps1 b/plugins/antianqi/mcode-island/pin-island.ps1 index 36c5f980..061bd186 100644 --- a/plugins/antianqi/mcode-island/pin-island.ps1 +++ b/plugins/antianqi/mcode-island/pin-island.ps1 @@ -29,7 +29,13 @@ if (-not $proc) { $configDir = Join-Path $env:APPDATA 'mcode-island' if (!(Test-Path $configDir)) { New-Item -ItemType Directory -Path $configDir -Force | Out-Null } -$callerFile = Join-Path $configDir 'caller.json' +# Write pin.json, not caller.json. +# +# A pin is the user's explicit click target and has to outlive the agent-side +# state transitions: caller.json is rewritten on every notify, so a pin +# stored there would not survive the next status push, which would make +# "the manual pin beats the desktop app" unreachable. +$pinFile = Join-Path $configDir 'pin.json' $myPid = [int](Get-Process -Id $PID).Id $mySession = (Get-Process -Id $myPid).SessionId @@ -44,9 +50,9 @@ $payload = [PSCustomObject]@{ ts = (Get-Date).ToString('o') } | ConvertTo-Json -Compress -$tmpFile = "$callerFile.tmp" +$tmpFile = "$pinFile.tmp" [System.IO.File]::WriteAllText($tmpFile, $payload, [System.Text.Encoding]::UTF8) -Move-Item -Path $tmpFile -Destination $callerFile -Force +Move-Item -Path $tmpFile -Destination $pinFile -Force Write-Output ("OK: pin'd " + $proc.ProcessName + " (PID=" + $proc.Id + ", hwnd=" + $hwnd + ")") Write-Output (" title: " + $proc.MainWindowTitle) diff --git a/plugins/antianqi/mcode-island/scripts/smoke.mjs b/plugins/antianqi/mcode-island/scripts/smoke.mjs index c9914eb1..45b56d68 100644 --- a/plugins/antianqi/mcode-island/scripts/smoke.mjs +++ b/plugins/antianqi/mcode-island/scripts/smoke.mjs @@ -390,48 +390,192 @@ const main = async () => { 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. + // 5c2. Click target resolution (desktop-app adaptation). + // + // The click used to be a plain show/hide toggle of the caller's terminal. + // In the desktop app the agent is hosted by the app and that terminal + // window belongs to the app session, so "hide the terminal" is + // meaningless there -- the agent keeps running behind a static pill. + // What the user wants is "take me back to the app window". + // + // The contract is a priority chain, resolved on every click: + // 1. manual pin (pin.json) -> focus that window + // 2. desktop app main window -> focus it + // 3. the caller's terminal -> keep the old show/hide toggle + // + // These locks are text-shaped (the widget is a WPF app; there is no + // headless way to click it), so every one of them has to anchor on the + // code that actually carries the contract -- and has to be able to fail. 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'); + + // (a) the resolution helpers exist as named functions + for (const [name, re] of [ + ['Resolve-CallerWindow', /function Resolve-CallerWindow\b/], + ['Toggle-CallerWindow', /function Toggle-CallerWindow\b/], + ['Resolve-ClickTarget', /function Resolve-ClickTarget\b/], + ['Resolve-PinnedWindow', /function Resolve-PinnedWindow\b/], + ['Find-DesktopAppWindow', /function Find-DesktopAppWindow\b/], + ['Invoke-ClickTarget', /function Invoke-ClickTarget\b/], + ['Focus-Window', /function Focus-Window\b/], + ['Toggle-Window', /function Toggle-Window\b/], + ]) { + if (re.test(widget)) { + out('PASS', `mcode-island.ps1: ${name} function present`); + } else { + out('FAIL', `mcode-island.ps1: ${name} function missing (click-target contract broken)`); + } } - 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'); + + // (b) the priority chain is in the right ORDER inside + // Resolve-ClickTarget. Order is the whole contract: a pin that loses + // to the desktop app is useless for a CLI user who happens to have + // the app open at the same time. + const clickTargetBody = (widget.match(/function Resolve-ClickTarget[\s\S]*?\n\}\n/) || [''])[0]; + if (!clickTargetBody) { + out('FAIL', 'mcode-island.ps1: Resolve-ClickTarget body not found (priority drift lock skipped)'); } else { - out('PASS', 'mcode-island.ps1: Toggle-CallerWindow gates on IsWindowVisible'); + const pinAt = clickTargetBody.indexOf('Resolve-PinnedWindow'); + const appAt = clickTargetBody.indexOf('Find-DesktopAppWindow'); + const callerAt = clickTargetBody.indexOf('Resolve-CallerWindow'); + if (pinAt < 0 || appAt < 0 || callerAt < 0) { + out('FAIL', `mcode-island.ps1: Resolve-ClickTarget does not consult all three sources (pin=${pinAt} app=${appAt} caller=${callerAt})`); + } else if (!(pinAt < appAt && appAt < callerAt)) { + out('FAIL', `mcode-island.ps1: Resolve-ClickTarget priority order is wrong -- it must be pin -> desktop app -> caller terminal (offsets pin=${pinAt} app=${appAt} caller=${callerAt})`); + } else { + out('PASS', 'mcode-island.ps1: Resolve-ClickTarget resolves pin -> desktop app -> caller terminal in that order'); + } + if (!/Mode\s*=\s*'focus'/.test(clickTargetBody)) { + out('FAIL', "mcode-island.ps1: Resolve-ClickTarget does not mark any target as Mode = 'focus'"); + } else { + out('PASS', "mcode-island.ps1: Resolve-ClickTarget marks the pin / desktop app as Mode = 'focus'"); + } + if (!/Mode\s*=\s*'toggle'/.test(clickTargetBody)) { + out('FAIL', "mcode-island.ps1: Resolve-ClickTarget does not keep Mode = 'toggle' for the caller's terminal"); + } else { + out('PASS', "mcode-island.ps1: Resolve-ClickTarget keeps the caller's terminal on Mode = 'toggle'"); + } } - // The click handler must call Toggle-CallerWindow, not Focus. - // We anchor on the MouseLeftButtonUp event to scope the check. + + // (c) the click handler goes through the dispatcher, not straight to + // the toggle. A handler that still called Toggle-CallerWindow would + // hide the terminal instead of focusing the app. 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 if (!/Invoke-ClickTarget\b/.test(clickMatch[0])) { + out('FAIL', 'mcode-island.ps1: MouseLeftButtonUp does not invoke Invoke-ClickTarget (the click would skip the desktop-app target)'); + } else if (/Toggle-CallerWindow\b/.test(clickMatch[0])) { + out('FAIL', 'mcode-island.ps1: MouseLeftButtonUp still calls Toggle-CallerWindow directly -- it must go through Invoke-ClickTarget'); } else { - out('PASS', 'mcode-island.ps1: MouseLeftButtonUp invokes Toggle-CallerWindow (single click toggles show/hide)'); + out('PASS', 'mcode-island.ps1: MouseLeftButtonUp invokes Invoke-ClickTarget (pin / desktop app / terminal resolved per click)'); + } + + // (d) the dispatcher honours the resolved Mode. Always focusing would + // break the CLI "click to hide my terminal" muscle memory; always + // toggling would hide the desktop app window. + const dispatchBody = (widget.match(/function Invoke-ClickTarget[\s\S]*?\n\}\n/) || [''])[0]; + if (!dispatchBody) { + out('FAIL', 'mcode-island.ps1: Invoke-ClickTarget body not found (mode dispatch drift lock skipped)'); + } else if (!/Focus-Window\s+\$t\b/.test(dispatchBody)) { + out('FAIL', 'mcode-island.ps1: Invoke-ClickTarget does not call Focus-Window for a focus target'); + } else if (!/Toggle-Window\s+\$t\b/.test(dispatchBody)) { + out('FAIL', 'mcode-island.ps1: Invoke-ClickTarget does not call Toggle-Window for a toggle target'); + } else if (!/\$t\.Mode\s*-eq\s*'focus'/.test(dispatchBody)) { + out('FAIL', "mcode-island.ps1: Invoke-ClickTarget does not branch on the resolved Mode -- a dispatcher that always picks one behaviour breaks the other target kind"); + } else { + out('PASS', 'mcode-island.ps1: Invoke-ClickTarget dispatches focus vs toggle by the resolved Mode'); + } + + // (e) the desktop app is found by process name, and only inside the + // widget's own session. A cross-session match could focus another + // session's app instance. + const appFn = (widget.match(/function Find-DesktopAppWindow[\s\S]*?\n\}\n/) || [''])[0]; + if (!appFn) { + out('FAIL', 'mcode-island.ps1: Find-DesktopAppWindow body not found'); + } else { + if (!/MiniMax Inside Code/.test(widget)) { + out('FAIL', "mcode-island.ps1: the desktop app allowlist does not contain 'MiniMax Inside Code' (the desktop app window would never be found)"); + } else { + out('PASS', "mcode-island.ps1: the desktop app allowlist contains 'MiniMax Inside Code'"); + } + if (!/SessionId\s*-eq\s*\$mySession/.test(appFn)) { + out('FAIL', 'mcode-island.ps1: Find-DesktopAppWindow does not restrict the search to the widget session'); + } else { + out('PASS', 'mcode-island.ps1: Find-DesktopAppWindow only considers processes in the widget session'); + } + } + + // (f) focusing must never hide. Focus-Window serves the desktop app + // and a pinned window; hiding the app would take the agent surface + // away from the user, which is exactly the bug this adaptation fixes. + const focusBody = (widget.match(/function Focus-Window[\s\S]*?\n\}\n/) || [''])[0]; + if (!focusBody) { + out('FAIL', 'mcode-island.ps1: Focus-Window body not found'); + } else if (/ShowWindow\(\s*\$Target\.Hwnd\s*,\s*0\s*\)/.test(focusBody)) { + out('FAIL', 'mcode-island.ps1: Focus-Window calls ShowWindow(SW_HIDE) -- focusing the desktop app must never hide it'); + } else if (!/SetForegroundWindow/.test(focusBody)) { + out('FAIL', 'mcode-island.ps1: Focus-Window does not call SetForegroundWindow'); + } else { + out('PASS', 'mcode-island.ps1: Focus-Window raises and activates without ever hiding the target'); + } + + // (g) the pin has to be sticky. It lives in pin.json, not in the + // caller.json that every status transition overwrites: a pin stored + // in caller.json dies at the next notify, which makes "the pin beats + // the desktop app" unreachable in practice. + const pinBody = (widget.match(/function Resolve-PinnedWindow[\s\S]*?\n\}\n/) || [''])[0]; + if (!pinBody) { + out('FAIL', 'mcode-island.ps1: Resolve-PinnedWindow body not found'); + } else if (!/Join-Path[^\n]*pin\.json/.test(pinBody)) { + // Anchor on the path expression, not on the bare file name: a + // Dbg message that merely mentions pin.json must not satisfy + // this lock. + out('FAIL', 'mcode-island.ps1: Resolve-PinnedWindow does not read pin.json (the pin would be overwritten by every status transition)'); + } else { + out('PASS', 'mcode-island.ps1: Resolve-PinnedWindow reads the sticky pin.json'); + } + + // (h) the writer has to write the file the resolver reads, and it + // must not write caller.json -- that file is overwritten by every + // status transition, so a pin stored there is gone at the next + // notify. + const pinWriterPath = join(PLUGIN_ROOT, 'pin-island.ps1'); + if (!(await exists(pinWriterPath))) { + out('FAIL', 'pin-island.ps1 missing (pin writer drift lock skipped)'); + } else { + const pinWriter = await readFile(pinWriterPath, 'utf8'); + // Anchor on the destination expression, not on the bare file + // name: the file explains in a comment why it does NOT use + // caller.json, and that comment must not satisfy this lock. + if (!/Join-Path[^\n]*'pin\.json'/.test(pinWriter)) { + out('FAIL', 'pin-island.ps1 does not write pin.json (the pin would not survive the next status transition)'); + } else if (/Join-Path[^\n]*'caller\.json'/.test(pinWriter)) { + out('FAIL', 'pin-island.ps1 still writes caller.json (the pin would be overwritten by every status transition)'); + } else { + out('PASS', 'pin-island.ps1 writes the sticky pin.json'); + } + } + + // (i) unpin has to clear the sticky pin, otherwise the pin survives + // the one command that exists to remove it. + const cmdPath = join(PLUGIN_ROOT, 'mcode-island.cmd'); + if (!(await exists(cmdPath))) { + out('FAIL', 'mcode-island.cmd missing (unpin drift lock skipped)'); + } else { + const cmd = await readFile(cmdPath, 'utf8'); + // The label has to start the line: the dispatch table also + // mentions "unpin" ("if /i "%1"=="unpin" goto :unpin"). + const unpinBlock = (cmd.match(/\r?\n:unpin[\s\S]*?exit \/b %errorlevel%/i) || [''])[0]; + if (!unpinBlock) { + out('FAIL', 'mcode-island.cmd: the unpin command is missing'); + } else if (!/Remove-Item[^\n]*'mcode-island\\pin\.json'/.test(unpinBlock)) { + // Anchor on the removal itself: the command's own output text + // mentions "pin.json" and must not satisfy this lock. + out('FAIL', 'mcode-island.cmd: unpin does not clear pin.json (the pinned window would stay the click target)'); + } else { + out('PASS', 'mcode-island.cmd: unpin clears the sticky pin.json'); + } } // Round-15: Toggle's restore branch must call SW_MAXIMIZE (3), not @@ -442,9 +586,9 @@ const main = async () => { // 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/); + const toggleMatch = widget.match(/function Toggle-Window[\s\S]*?\n\}\n/); if (!toggleMatch) { - out('WARN', 'mcode-island.ps1: Toggle-CallerWindow body not found (drift lock skipped)'); + out('WARN', 'mcode-island.ps1: Toggle-Window body not found (drift lock skipped)'); } else { const toggleBody = toggleMatch[0]; // Extract the `else` branch (the restore path) so the check @@ -453,10 +597,10 @@ const main = async () => { 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-Window else branch not parseable'); + } else if (!/ShowWindow\(\s*\$Target\.Hwnd\s*,\s*3\s*\)/.test(restoreBody)) { out('FAIL', 'mcode-island.ps1: Toggle restore branch does not call SW_MAXIMIZE (ShowWindow(_, 3)). A regression to SW_SHOW (5) or SW_RESTORE (9) re-shows the window at its pre-hide size (e.g., 480x84 strip if WT got accidentally resized).'); - } else if (/ShowWindow\(\s*\$r\.Hwnd\s*,\s*5\s*\)/.test(restoreBody)) { + } else if (/ShowWindow\(\s*\$Target\.Hwnd\s*,\s*5\s*\)/.test(restoreBody)) { out('FAIL', 'mcode-island.ps1: Toggle restore branch calls SW_SHOW (5) in addition to SW_MAXIMIZE — keep only SW_MAXIMIZE; SW_SHOW re-shows at pre-hide size and defeats the maximize intent.'); } else { out('PASS', 'mcode-island.ps1: Toggle restore branch forces SW_MAXIMIZE (full-screen on show, fixes 480x84 strip bug)'); @@ -474,7 +618,7 @@ const main = async () => { // 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)) { + } else if (!/SetWindowPos\([^)]*\$wa\.|SetWindowPos\(\$Target\.Hwnd,[^,]+,\s*\$wa\.Left,\s*\$wa\.Top,\s*\$cx,\s*\$cy/.test(toggleBody)) { out('FAIL', 'mcode-island.ps1: Toggle restore branch has monitor query but does not SetWindowPos with work-area coords. The contract is: read monitor work area, then SetWindowPos with explicit (Left, Top, cx, cy) — never rely on SW_MAXIMIZE alone for size.'); } else { out('PASS', 'mcode-island.ps1: Toggle restore branch forces work-area size via MonitorFromWindow + SetWindowPos (fills 2560x1440 physical monitor, not just 1920x1080 logical)'); diff --git a/plugins/antianqi/mcode-island/scripts/test-substep-progress.mjs b/plugins/antianqi/mcode-island/scripts/test-substep-progress.mjs index 082c43a9..0bb49bfc 100644 --- a/plugins/antianqi/mcode-island/scripts/test-substep-progress.mjs +++ b/plugins/antianqi/mcode-island/scripts/test-substep-progress.mjs @@ -537,12 +537,12 @@ ${call} // --------------------------------------------------------------------------- { - const toggle = extractFn(widget, 'Toggle-CallerWindow'); + const toggle = extractFn(widget, 'Toggle-Window'); 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'); + 'could not find the HWND_TOP SetWindowPos call in Toggle-Window'); if (zorderCall) { // Pull the flags expression that feeds this specific call. Anchor on diff --git a/plugins/antianqi/mcode-island/skills/SKILL.md b/plugins/antianqi/mcode-island/skills/SKILL.md index 949428e1..918365be 100644 --- a/plugins/antianqi/mcode-island/skills/SKILL.md +++ b/plugins/antianqi/mcode-island/skills/SKILL.md @@ -26,9 +26,21 @@ theme. Six states with distinct color and motion: | `done` | green | ✓ | step finished, more to do | | `error` | red | ✕ | tool or step failed | -Click the pill to switch focus back to the originating terminal tab. Run -`mcode-island pin` from inside a terminal to fix the focus target explicitly -(useful when the auto-detected HWND is wrong, e.g. Windows Terminal multi-tab). +Click the pill to go back to where the work happens. The target is resolved on +every click, in this order: + +1. **A manual pin** (`mcode-island pin`) — that window is focused. +2. **The desktop app window** (`MiniMax Inside Code` / `MiniMax Code` / + `Mavis`) — it is focused, never hidden. In the desktop app the agent runs + inside the app, so hiding the terminal behind it would leave the user + staring at a static pill while the agent keeps running. +3. **The originating terminal** (`caller.json`) — the original behaviour: one + click hides the terminal, the next brings it back maximized. + +Run `mcode-island pin` from inside a terminal to override the target +explicitly (useful when the auto-detected HWND is wrong, e.g. Windows Terminal +multi-tab, or when the desktop app is open next to a CLI session). +`mcode-island unpin` clears the pin. ## Two ways to drive the pill @@ -137,10 +149,11 @@ deliberately avoids hard-coded paths so any user / any install location works. ## Expected result After each push (or after each hook fires), the widget on the user's primary -display updates within ~400 ms (one polling cycle). On click, the originating -terminal tab regains focus. The widget is intentionally hard to kill: Alt+F4 -hides it, not closes it, and `mcode-island show` re-raises the hidden window -in under 1 second. +display updates within ~400 ms (one polling cycle). On click, the widget +focuses the desktop app window (or the pinned window, or the originating +terminal tab — see the click-target order above). The widget is intentionally +hard to kill: Alt+F4 hides it, not closes it, and `mcode-island show` re-raises +the hidden window in under 1 second. ## User-side management @@ -149,8 +162,8 @@ mcode-island REM start the widget (idempotent) mcode-island stop REM stop the widget mcode-island status REM show PID + recent log mcode-island show REM re-raise hidden widget -mcode-island pin REM lock focus target to current foreground window -mcode-island unpin REM clear focus target +mcode-island pin REM pin the click target to the current foreground window +mcode-island unpin REM clear the pinned click target mcode-island autostart-on REM register for Windows logon mcode-island autostart-off REM unregister ``` @@ -172,6 +185,7 @@ All widget state lives under `%APPDATA%\mcode-island\`: | ---------------- | ----------------------------------------------------- | | `status.json` | current state (widget polls this every 400 ms) | | `caller.json` | originating terminal HWND / PID (for click-to-focus) | +| `pin.json` | manually pinned click target (survives status pushes) | | `config.json` | pill position, size, opacity (saved on drag) | | `widget.pid` | widget process PID (used by start/stop/status) | | `island.log` | append-only state transition history | diff --git a/plugins/antianqi/mcode-island/skills/mcode-island/SKILL.md b/plugins/antianqi/mcode-island/skills/mcode-island/SKILL.md index 0d1a8c40..75f1696d 100644 --- a/plugins/antianqi/mcode-island/skills/mcode-island/SKILL.md +++ b/plugins/antianqi/mcode-island/skills/mcode-island/SKILL.md @@ -26,9 +26,21 @@ theme. Six states with distinct color and motion: | `done` | green | ✓ | step finished, more to do | | `error` | red | ✕ | tool or step failed | -Click the pill to switch focus back to the originating terminal tab. Run -`mcode-island pin` from inside a terminal to fix the focus target explicitly -(useful when the auto-detected HWND is wrong, e.g. Windows Terminal multi-tab). +Click the pill to go back to where the work happens. The target is resolved on +every click, in this order: + +1. **A manual pin** (`mcode-island pin`) — that window is focused. +2. **The desktop app window** (`MiniMax Inside Code` / `MiniMax Code` / + `Mavis`) — it is focused, never hidden. In the desktop app the agent runs + inside the app, so hiding the terminal behind it would leave the user + staring at a static pill while the agent keeps running. +3. **The originating terminal** (`caller.json`) — the original behaviour: one + click hides the terminal, the next brings it back maximized. + +Run `mcode-island pin` from inside a terminal to override the target +explicitly (useful when the auto-detected HWND is wrong, e.g. Windows Terminal +multi-tab, or when the desktop app is open next to a CLI session). +`mcode-island unpin` clears the pin. ## Two ways to drive the pill @@ -164,10 +176,11 @@ deliberately avoids hard-coded paths so any user / any install location works. ## Expected result After each push (or after each hook fires), the widget on the user's primary -display updates within ~400 ms (one polling cycle). On click, the originating -terminal tab regains focus. The widget is intentionally hard to kill: Alt+F4 -hides it, not closes it, and `mcode-island show` re-raises the hidden window -in under 1 second. +display updates within ~400 ms (one polling cycle). On click, the widget +focuses the desktop app window (or the pinned window, or the originating +terminal tab — see the click-target order above). The widget is intentionally +hard to kill: Alt+F4 hides it, not closes it, and `mcode-island show` re-raises +the hidden window in under 1 second. ## User-side management @@ -176,8 +189,8 @@ mcode-island REM start the widget (idempotent) mcode-island stop REM stop the widget mcode-island status REM show PID + recent log mcode-island show REM re-raise hidden widget -mcode-island pin REM lock focus target to current foreground window -mcode-island unpin REM clear focus target +mcode-island pin REM pin the click target to the current foreground window +mcode-island unpin REM clear the pinned click target mcode-island autostart-on REM register for Windows logon mcode-island autostart-off REM unregister ``` @@ -199,6 +212,7 @@ All widget state lives under `%APPDATA%\mcode-island\`: | ---------------- | ----------------------------------------------------- | | `status.json` | current state (widget polls this every 400 ms) | | `caller.json` | originating terminal HWND / PID (for click-to-focus) | +| `pin.json` | manually pinned click target (survives status pushes) | | `config.json` | pill position, size, opacity (saved on drag) | | `widget.pid` | widget process PID (used by start/stop/status) | | `island.log` | append-only state transition history | From 830aece4bb97e6ed8633889c89703da01814a737 Mon Sep 17 00:00:00 2001 From: antianqi Date: Fri, 23 Oct 2026 09:10:58 +0800 Subject: [PATCH 33/38] fix(mcode-island): the detector follows the new session even when its last line is fat Reported symptom: "the first test after the start is fine, but in a new conversation the pill stops updating and keeps showing the previous task's finished state". Root cause, reproduced against the real detector: One session-log line can be much larger than the tail window the reader looked at. An agent that reads a screenshot writes the base64 image into a single line -- 200 KB to 560 KB in the wild, and this machine's own sessions carry lines of 561 KB and 1.4 MB. The reader took the last 64 KB of the file and parsed the fragments backwards, so the window held nothing but a fragment of that one line. ConvertFrom-Json threw on the fragment, and the catch-all answered with the previously cached inference -- a message that belonged to an EARLIER session, often an earlier conversation. The cache made it stick: it was keyed on the mtime alone, so the mtime of the new file was recorded before the failed read, and every later poll returned that same foreign message until the file grew again. Three defects, one fix: * The tail reader now grows its window until a line actually parses, and it reads the last COMPLETE line (the first fragment of the window is a cut line and is never trusted unless the whole file is inside the window). A fat line costs a few hundred KB of read, once per change. * The message cache is keyed on path AND mtime, so a session switch can never answer with the previous file's message. * A read failure now answers "no information" instead of another session's message. The status stays as it was, which is the honest answer. Validation ----------- * Reproduced first: with the pre-fix detector, a new session whose only line is 200 KB reported the PREVIOUS session's tool ("Read" instead of "Ran"). After the fix it reports the new session's own tool. * The regression test is behavioral: the real functions are extracted from the real detector and run against a synthetic session tree, so a text-shaped lock cannot pass while the behaviour is broken. * Negative injection: the test fails on the pre-fix detector (the reader is not extractable) AND on a mutant that keeps the function name but restores the fixed 64 KB window (expected "bash", got ""). The unmutated source passes. Test evidence ------------- * node scripts/smoke.mjs -> 150 pass, 7 warn, 0 fail (was 146). * node scripts/test-substep-progress.mjs -> 119 pass, 0 fail (was 116). * pwsh -File scripts/test-windows-workflow-local.ps1 -> all 5 steps OK. * Windows PowerShell 5.1 parser: 0 errors on mcode-status-detect.ps1. Design compliance ----------------- * Detector-only change: no credential, no network request, no new file. * The reader is still bounded: one line may not pull more than 8 MB into memory, and the mtime cache keeps the work off the hot path. * The Marketplace package ships this detector, so the fix has to be republished there as well (v0.8.1). --- .../mcode-island/mcode-status-detect.ps1 | 97 ++++++++++++----- .../antianqi/mcode-island/scripts/smoke.mjs | 41 +++++++ .../scripts/test-substep-progress.mjs | 100 ++++++++++++++++++ 3 files changed, 211 insertions(+), 27 deletions(-) diff --git a/plugins/antianqi/mcode-island/mcode-status-detect.ps1 b/plugins/antianqi/mcode-island/mcode-status-detect.ps1 index b370e414..d14e1fae 100644 --- a/plugins/antianqi/mcode-island/mcode-status-detect.ps1 +++ b/plugins/antianqi/mcode-island/mcode-status-detect.ps1 @@ -256,7 +256,7 @@ if (-not $sessionsRoot) { # 全局状态 $script:lastDetectedState = '' -$script:lastMsgsSnapshot = @{ mtime = ''; lastInferred = $null } +$script:lastMsgsSnapshot = @{ path = ''; mtime = ''; lastInferred = $null } # 5s 缓存:最新 session log 路径 # 避免每秒调 Get-LatestSessionFile(每次都涉及 Get-ChildItem @@ -331,42 +331,85 @@ function Get-LatestSessionFile { return $best } +# Reads the last COMPLETE json line of a session log. +# +# The old implementation read a fixed 64 KB tail window and parsed the +# fragments backwards. One session-log line can be far bigger than that: an +# agent that reads a screenshot writes the base64 image into a single line +# (200 KB - 560 KB in the wild), and a big file read is just as fat. The +# window then held nothing but a fragment of that line, ConvertFrom-Json +# threw on it, and the catch-all returned the previously cached inference -- +# which belonged to an earlier file, often an earlier conversation. The pill +# then showed the previous task's state for the whole new conversation. +# +# So the window grows until a line actually parses. Reading one fat line is +# cheap (a few hundred KB, once per change); reporting another session's +# state is not. +function Get-LastJsonLine($file) { + $fi = [System.IO.FileInfo]::new($file) + $len = $fi.Length + if ($len -le 0) { return $null } + # A single line is never allowed to pull more than 8 MB into memory. + $cap = [Math]::Min($len, 8MB) + $window = [Math]::Min($len, 65536) + while ($window -le $cap) { + $fs = $null + try { + $fs = [System.IO.File]::Open($fi.FullName, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite) + [void]$fs.Seek(-$window, [System.IO.SeekOrigin]::End) + $buf = New-Object byte[] $window + [void]$fs.Read($buf, 0, $window) + } catch { + return $null + } finally { + if ($fs) { $fs.Close() } + } + $text = [System.Text.Encoding]::UTF8.GetString($buf) + $parts = $text -split "`n" + # Walk backwards. Index 0 is the fragment the window cut into, so it is + # only trustworthy when the whole file is inside the window. + for ($i = $parts.Length - 1; $i -ge 1; $i--) { + $line = $parts[$i].TrimEnd("`r") + if (-not $line -or $line.Length -le 10) { continue } + try { return ($line | ConvertFrom-Json) } catch { continue } + } + if ($window -ge $len) { + # The whole file is here: the first fragment is a complete line too. + $line = $parts[0].TrimEnd("`r") + if ($line.Length -gt 10) { + try { return ($line | ConvertFrom-Json) } catch { return $null } + } + } + $next = $window * 2 + if ($next -gt $cap) { $next = $cap } + if ($next -eq $window) { break } + $window = $next + } + return $null +} + function Read-LastMessage($file) { if (-not $file) { return $null } try { $fi = [System.IO.FileInfo]::new($file) $mtime = $fi.LastWriteTime.ToString($FMT_O_FULL) - if ($script:lastMsgsSnapshot.mtime -eq $mtime) { + # The cache is keyed on path AND mtime. Keying it on the mtime alone + # let a session switch return the previous file's message, because two + # files touched in the same tick share an mtime. + if ($script:lastMsgsSnapshot.path -eq $file -and $script:lastMsgsSnapshot.mtime -eq $mtime) { # mtime 没变:返回上次推断结果(保持 idle 兜底可达) return $script:lastMsgsSnapshot.lastInferred } + $msg = Get-LastJsonLine $file + $script:lastMsgsSnapshot.path = $file $script:lastMsgsSnapshot.mtime = $mtime - $fs = [System.IO.File]::Open($fi.FullName, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite) - try { - $len = $fs.Length - if ($len -eq 0) { return $null } - $lookback = [Math]::Min($len, 65536) - $fs.Seek(-$lookback, [System.IO.SeekOrigin]::End) | Out-Null - $buf = New-Object byte[] $lookback - $fs.Read($buf, 0, $lookback) | Out-Null - $text = [System.Text.Encoding]::UTF8.GetString($buf) - $parts = $text -split "`n" - for ($i = $parts.Length - 1; $i -ge 0; $i--) { - $line = $parts[$i].TrimEnd("`r") - if ($line -and $line.Length -gt 10) { - # ConvertFrom-Json is gated by the mtime cache above, so this - # pipeline only fires when the file actually grew. Acceptable. - $msg = ($line | ConvertFrom-Json) - $script:lastMsgsSnapshot.lastInferred = $msg - return $msg - } - } - return $null - } finally { - $fs.Close() - } + $script:lastMsgsSnapshot.lastInferred = $msg + return $msg } catch { - return $script:lastMsgsSnapshot.lastInferred + # A file we cannot read right now (locked, mid-write) must not make us + # report another session's state. Returning $null leaves the current + # status untouched, which is the honest answer: "no new information". + return $null } } diff --git a/plugins/antianqi/mcode-island/scripts/smoke.mjs b/plugins/antianqi/mcode-island/scripts/smoke.mjs index 45b56d68..eb1b42fe 100644 --- a/plugins/antianqi/mcode-island/scripts/smoke.mjs +++ b/plugins/antianqi/mcode-island/scripts/smoke.mjs @@ -863,6 +863,47 @@ const main = async () => { } else { out('FAIL', "mcode-status-detect.ps1: ask_user is still mapped to the 'task' family, so a blocked agent wears the delegation color"); } + + // (h) The session-log reader must survive a fat last line. + // + // One session-log line can be hundreds of KB (a screenshot read + // writes the base64 image into a single line). A reader that looks at + // a fixed 64 KB tail window sees nothing but a fragment of that + // line, fails to parse it, and answers with the previously cached + // inference -- which belongs to an EARLIER session. The pill then + // shows the previous task's state for the whole new conversation. + // This is the reported bug; the behavioral proof lives in + // test-substep-progress.mjs, these are the cheap guards. + if (/function Get-LastJsonLine\b/.test(detect)) { + out('PASS', 'mcode-status-detect.ps1: the session-log reader has a dedicated tail-line reader'); + } else { + out('FAIL', 'mcode-status-detect.ps1: no Get-LastJsonLine -- the reader is still the fixed 64 KB window that drops a fat last line'); + } + if (/\$window\s*\*\s*2/.test(detect)) { + out('PASS', 'mcode-status-detect.ps1: the tail window grows until a line parses'); + } else { + out('FAIL', 'mcode-status-detect.ps1: the tail window never grows -- a last line bigger than the window can never be read'); + } + // The cache must be keyed on the path as well as the mtime: a + // session switch must never answer with the previous file's message. + if (/\$script:lastMsgsSnapshot\.path\s*-eq\s*\$file/.test(detect)) { + out('PASS', 'mcode-status-detect.ps1: the message cache is keyed on path AND mtime'); + } else { + out('FAIL', 'mcode-status-detect.ps1: the message cache is keyed on the mtime alone -- a session switch can return the previous file\'s message'); + } + // A read failure must answer "no information", never another + // session's message. + const readLastBody = (detect.match(/function Read-LastMessage[\s\S]*?\n\}\n/) || [''])[0]; + if (!readLastBody) { + out('FAIL', 'mcode-status-detect.ps1: Read-LastMessage body not found (fat-line drift lock skipped)'); + } else { + const catchBlock = (readLastBody.match(/catch\s*\{[\s\S]*?\n\s{2,}\}/) || [''])[0]; + if (/lastInferred/.test(catchBlock)) { + out('FAIL', 'mcode-status-detect.ps1: a read failure answers with the cached inference -- that inference belongs to an earlier file, so the pill shows the previous conversation'); + } else { + out('PASS', "mcode-status-detect.ps1: a read failure never answers with another session's message"); + } + } } // 5e. Drift lock: the widget must consume `family` and apply it ONLY to diff --git a/plugins/antianqi/mcode-island/scripts/test-substep-progress.mjs b/plugins/antianqi/mcode-island/scripts/test-substep-progress.mjs index 0bb49bfc..92070198 100644 --- a/plugins/antianqi/mcode-island/scripts/test-substep-progress.mjs +++ b/plugins/antianqi/mcode-island/scripts/test-substep-progress.mjs @@ -1092,6 +1092,106 @@ if (process.platform !== 'win32') { } } +// --------------------------------------------------------------------------- +// 9. The detector follows the NEW session even when its last line is fat +// --------------------------------------------------------------------------- +// +// The reported bug: "after the first test the state is fine, but in a new +// conversation the pill stops updating and keeps showing the previous +// task's finished state". +// +// The mechanism: a session-log line can be far bigger than the tail window +// the reader used to look at. An agent that reads a screenshot writes the +// base64 image into ONE line (200 KB - 560 KB in the wild), so the 64 KB +// window held nothing but a fragment of that line. The fragment did not +// parse, and the catch-all answered with the PREVIOUSLY cached inference +// -- a message that belonged to an earlier session. The pill then showed +// the previous task's state for the whole new conversation. +// +// The regression is behavioral on purpose: the real functions are extracted +// from the real detector and run against a synthetic session tree, so a +// text-shaped lock cannot pass while the behaviour is broken. +{ + const detectPath = join(PLUGIN_ROOT, 'mcode-status-detect.ps1'); + const detect = readFileSync(detectPath, 'utf8'); + + let readLastMessage; + let getLastJsonLine; + try { + readLastMessage = extractFn(detect, 'Read-LastMessage'); + getLastJsonLine = extractFn(detect, 'Get-LastJsonLine'); + } catch (e) { + bad('the session-log reader is extractable', e.message); + } + + if (!readLastMessage || !getLastJsonLine) { + bad('the tail reader handles a fat last line', + 'Get-LastJsonLine / Read-LastMessage not found: the reader is still the 64 KB window that drops a fat line'); + } else { + ok('the session-log reader is extractable'); + + const sessions = join(TMP, 'sessions'); + const oldDir = join(sessions, '2026', '10', '23', '09-00-00-000-session_old'); + const newDir = join(sessions, '2026', '10', '23', '10-00-00-000-session_new'); + mkdirSync(oldDir, { recursive: true }); + mkdirSync(newDir, { recursive: true }); + + const oldFile = join(oldDir, 'messages.jsonl'); + const newFile = join(newDir, 'messages.jsonl'); + + // The previous task: its last line is a `read` toolResult. + const oldLine = JSON.stringify({ + message_id: 'msg-old', + turn_id: 'turn-old', + message: { + role: 'toolResult', + toolName: 'read', + content: [{ type: 'text', text: 'old task done' }], + }, + }); + writeFileSync(oldFile, oldLine + '\n', 'utf8'); + + // The new conversation: ONE line of 200 KB, exactly the shape a + // screenshot read produces. + const fatLine = JSON.stringify({ + message_id: 'msg-new', + turn_id: 'turn-new', + message: { + role: 'toolResult', + toolName: 'bash', + content: [{ type: 'text', text: 'x'.repeat(200000) }], + }, + }); + writeFileSync(newFile, fatLine, 'utf8'); + + const script = ` +$ErrorActionPreference = 'Stop' +$FMT_O_FULL = 'O' +$script:lastMsgsSnapshot = @{ path = ''; mtime = ''; lastInferred = $null } +${getLastJsonLine} +${readLastMessage} +$old = Read-LastMessage '${oldFile.replace(/'/g, "''")}' +$new = Read-LastMessage '${newFile.replace(/'/g, "''")}' +Write-Output ("OLD=" + [string]$old.message.toolName) +Write-Output ("NEW=" + [string]$new.message.toolName) +`; + + let out = ''; + try { + out = runPs(script); + } catch (e) { + bad('the tail reader handles a fat last line', + `the extracted reader threw: ${(e.stdout || e.message || '').toString().slice(0, 300)}`); + } + + const oldTool = (out.match(/OLD=(\S*)/) || [])[1]; + const newTool = (out.match(/NEW=(\S*)/) || [])[1]; + + eq('the reader returns the previous session\'s last message', oldTool, 'read'); + eq('the reader follows the new session whose last line is 200 KB', newTool, 'bash'); + } +} + // --------------------------------------------------------------------------- // summary // --------------------------------------------------------------------------- From e379c206169812bf24f1798636cad8f7994340a5 Mon Sep 17 00:00:00 2001 From: antianqi Date: Fri, 23 Oct 2026 09:24:26 +0800 Subject: [PATCH 34/38] docs(mcode-island): record which session the detector follows, and why The detector follows the session that wrote its log most recently. That is a decision, not an accident: the conversation the user is looking at is the one writing to the log, and the runtime exposes no signal that would let a plugin pin a session or ask which window is focused. The boundary is written down too: with several conversations running at once the pill can briefly show a neighbour's state, and the detector deliberately does not try to disambiguate that. Two guards keep it small -- the state is re-derived on every change, and an unparseable log line never falls back to another session's message. --- plugins/antianqi/mcode-island/README.md | 29 +++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/plugins/antianqi/mcode-island/README.md b/plugins/antianqi/mcode-island/README.md index 83a42e45..c85338cd 100644 --- a/plugins/antianqi/mcode-island/README.md +++ b/plugins/antianqi/mcode-island/README.md @@ -92,6 +92,35 @@ optional `-Message`. A separate `mcode-status-detect.ps1` polls the runtime's pill still moves even when the agent forgets to push. See [`SKILL.md`](skills/mcode-island/SKILL.md) for the agent-side call patterns. +#### Which session the detector follows + +**The session that wrote its log most recently.** The detector scans every +session log under the runtime's session store and picks the file with the +newest mtime, then infers the state from that file's last complete line. + +This is a deliberate decision, not an accident. The conversation the user is +looking at is the one writing to the log, so "whoever wrote last" is the +active conversation. Anything smarter — pinning a session id, asking the +runtime which window is focused — needs a signal the runtime does not expose +to a plugin, and guessing one would make the pill wrong in the common case +to make it right in the rare one. + +**Known boundary.** When several conversations run at the same time (a +desktop session plus a CLI session, or two desktop windows), the detector +follows whichever wrote last, so the pill can briefly show a neighbouring +conversation's state. The detector deliberately does not try to disambiguate +this. Two guards keep the damage small: + +- the state is re-derived on every change, so the pill corrects itself as + soon as the followed session writes again; +- a log line that cannot be parsed never falls back to another session's + message (the reader grows its window until a complete line is found, and + a read failure reports "no new information" instead of a foreign state). + +This is also why the Marketplace build needs the detector: its package ships +no Hook material, so the detector is the only source of state that does not +depend on the agent cooperating. + ## Copyable example ### One-line install and run From fee26a23cda4f9a23b19b31c388b0bc690329f0f Mon Sep 17 00:00:00 2001 From: antianqi Date: Fri, 23 Oct 2026 10:03:45 +0800 Subject: [PATCH 35/38] feat(mcode-island): the CLI install and the Marketplace package pick different click targets The two are different products, and the click behaviour is where that shows: * the CLI runtime loads the io.minimax.mcode hooks, so the agent pushes the state itself, and a click keeps the CLI habit -- one click hides the terminal, the next brings it back; * the Marketplace package ships no Hook material (the submission rules forbid it), so the detector is the only source of state; and the agent is hosted by the desktop app, so the terminal belongs to the app session and hiding it leaves a static pill behind. There the click has to focus the app window instead. Until now the shared source hardcoded the desktop behaviour, so the CLI install would have lost its own habit the moment the desktop adaptation landed -- and the CLI user is the one with the app open all day, which is exactly the case where the two answers differ. So the source carries ONE switch: $script:clickPrefers = 'terminal' and the Marketplace builder flips that exact line to 'app', failing the build when it cannot find it. A renamed switch cannot silently ship the CLI behaviour to the desktop users. The pin still wins in both builds: an explicit `mcode-island pin` is the user's own choice and outranks the preference. Validation ----------- * The resolution is checked behaviorally on both sides: the resolver is extracted from the real file, both lookups are stubbed so a desktop app AND a caller terminal exist, and the CLI install has to resolve to the terminal with the show/hide toggle while the package has to resolve to the app window with focus. * On this branch the package half is skipped loudly (the package is not here); the CLI half is checked. * Negative injection: the drift lock fails when the switch is renamed, and the behavioural half fails when the resolver stops branching on it. Test evidence ------------- * node scripts/smoke.mjs -> 152 pass, 7 warn, 0 fail (was 150). * node scripts/test-substep-progress.mjs -> 122 pass, 0 fail, 1 skipped (the package half; this branch has no package). * Windows PowerShell 5.1 parser: 0 errors on mcode-island.ps1. Design compliance ----------------- * One implementation, two profiles. The behaviour difference is a single named switch, not a second copy of the resolver. * No credential, no network request, no new file. * The Marketplace package is rebuilt from this source; the builder owns the flip and fails loudly when the anchor is gone. --- .../antianqi/mcode-island/mcode-island.ps1 | 44 ++++++++--- .../antianqi/mcode-island/scripts/smoke.mjs | 16 ++++ .../scripts/test-substep-progress.mjs | 78 +++++++++++++++++++ 3 files changed, 128 insertions(+), 10 deletions(-) diff --git a/plugins/antianqi/mcode-island/mcode-island.ps1 b/plugins/antianqi/mcode-island/mcode-island.ps1 index 7ea72862..6c05cab0 100644 --- a/plugins/antianqi/mcode-island/mcode-island.ps1 +++ b/plugins/antianqi/mcode-island/mcode-island.ps1 @@ -776,23 +776,47 @@ function Find-DesktopAppWindow { return $null } +# Which target a click prefers when both are available. +# +# 'terminal' -- the caller's terminal first. The CLI habit: one click +# hides it, the next brings it back. +# 'app' -- the desktop app window first. The Marketplace build ships +# this one: in the desktop app the agent is hosted by the +# app and the terminal belongs to the app session, so hiding +# that terminal leaves a static pill behind. +# +# The Marketplace package is built with 'app'. The builder flips this one +# line and FAILS the build when it cannot find it, so a renamed switch can +# never silently ship the CLI behaviour to the desktop users. +$script:clickPrefers = 'terminal' + # Resolve the click target. Mode = focus (raise and activate only) / # toggle (show / hide). function Resolve-ClickTarget { - # 1) the manual pin + # 1) the manual pin -- the user's explicit choice, it always wins $pin = Resolve-PinnedWindow if ($pin) { return [PSCustomObject]@{ Hwnd = $pin.Hwnd; Pid = $pin.Pid; Exe = $pin.Exe; Mode = 'focus'; Origin = 'pin' } } - # 2) the desktop app main window - $app = Find-DesktopAppWindow - if ($app) { - return [PSCustomObject]@{ Hwnd = $app.Hwnd; Pid = $app.Pid; Exe = $app.Exe; Mode = 'focus'; Origin = 'desktop-app' } - } - # 3) the caller's terminal (old behaviour: show / hide) - $caller = Resolve-CallerWindow - if ($caller) { - return [PSCustomObject]@{ Hwnd = $caller.Hwnd; Pid = $caller.Pid; Exe = $caller.Exe; Mode = 'toggle'; Origin = 'caller' } + # 2) the preferred target, then the other one + if ($script:clickPrefers -eq 'app') { + $app = Find-DesktopAppWindow + if ($app) { + return [PSCustomObject]@{ Hwnd = $app.Hwnd; Pid = $app.Pid; Exe = $app.Exe; Mode = 'focus'; Origin = 'desktop-app' } + } + $caller = Resolve-CallerWindow + if ($caller) { + return [PSCustomObject]@{ Hwnd = $caller.Hwnd; Pid = $caller.Pid; Exe = $caller.Exe; Mode = 'toggle'; Origin = 'caller' } + } + } else { + $caller = Resolve-CallerWindow + if ($caller) { + return [PSCustomObject]@{ Hwnd = $caller.Hwnd; Pid = $caller.Pid; Exe = $caller.Exe; Mode = 'toggle'; Origin = 'caller' } + } + $app = Find-DesktopAppWindow + if ($app) { + return [PSCustomObject]@{ Hwnd = $app.Hwnd; Pid = $app.Pid; Exe = $app.Exe; Mode = 'focus'; Origin = 'desktop-app' } + } } return $null } diff --git a/plugins/antianqi/mcode-island/scripts/smoke.mjs b/plugins/antianqi/mcode-island/scripts/smoke.mjs index eb1b42fe..2b3eb669 100644 --- a/plugins/antianqi/mcode-island/scripts/smoke.mjs +++ b/plugins/antianqi/mcode-island/scripts/smoke.mjs @@ -578,6 +578,22 @@ const main = async () => { } } + // (j) the CLI install and the Marketplace package resolve the click + // target differently, and the source says which one it is. The + // Marketplace builder flips this ONE line to 'app' and fails the + // build when it cannot find it, so a renamed switch cannot silently + // ship the CLI behaviour to the desktop users. + if (/\$script:clickPrefers\s*=\s*'terminal'/.test(widget)) { + out('PASS', 'mcode-island.ps1: the CLI install prefers the caller terminal'); + } else { + out('FAIL', "mcode-island.ps1: the CLI install does not declare $script:clickPrefers = 'terminal' -- the Marketplace builder flips that exact line"); + } + if (/\$script:clickPrefers\s+-eq\s+'app'/.test(widget)) { + out('PASS', 'mcode-island.ps1: Resolve-ClickTarget branches on the preference'); + } else { + out('FAIL', 'mcode-island.ps1: Resolve-ClickTarget does not branch on $script:clickPrefers, so the two builds cannot differ'); + } + // 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 diff --git a/plugins/antianqi/mcode-island/scripts/test-substep-progress.mjs b/plugins/antianqi/mcode-island/scripts/test-substep-progress.mjs index 92070198..c300fd8d 100644 --- a/plugins/antianqi/mcode-island/scripts/test-substep-progress.mjs +++ b/plugins/antianqi/mcode-island/scripts/test-substep-progress.mjs @@ -1192,6 +1192,84 @@ Write-Output ("NEW=" + [string]$new.message.toolName) } } +// --------------------------------------------------------------------------- +// 10. The CLI install and the Marketplace package resolve the click target +// differently, and each build says so in the code +// --------------------------------------------------------------------------- +// +// The two are different products. The CLI runtime loads the +// io.minimax.mcode hooks, so the agent pushes the state itself and a click +// keeps the CLI habit of hiding the terminal and bringing it back. The +// Marketplace package ships no Hook material, the agent is hosted by the +// desktop app, and there the click has to focus the app window. +// +// So the source carries ONE switch and the package builder flips it. Both +// sides are checked behaviorally here: the resolver is extracted from the +// real file, the two lookups are stubbed so that BOTH a desktop app and a +// caller terminal exist, and the resolution has to pick the right one. +{ + const sourcePath = join(PLUGIN_ROOT, 'mcode-island.ps1'); + // The Marketplace package is only present on the branch that ships it. + const packagePath = join(PLUGIN_ROOT, '..', '..', '..', 'marketplace', 'mcode-island', 'mcode-island.ps1'); + + /** + * Resolve the click target against one file, with both a desktop app + * window and a caller terminal available. + */ + function resolveClickTarget(file) { + const src = readFileSync(file, 'utf8'); + const pref = (src.match(/\$script:clickPrefers\s*=\s*'([a-z]+)'/) || [])[1]; + if (!pref) throw new Error('the file does not declare $script:clickPrefers'); + const fn = extractFn(src, 'Resolve-ClickTarget'); + const script = ` +$ErrorActionPreference = 'Stop' +# Both targets exist, so the preference decides. +function Resolve-PinnedWindow { return $null } +function Find-DesktopAppWindow { return [PSCustomObject]@{ Hwnd = 1; Pid = 2; Exe = 'fake-app' } } +function Resolve-CallerWindow { return [PSCustomObject]@{ Hwnd = 3; Pid = 4; Exe = 'fake-caller'; Source = 'console-window' } } +$script:clickPrefers = '${pref}' +${fn} +$t = Resolve-ClickTarget +Write-Output ("ORIGIN=" + $t.Origin + " MODE=" + $t.Mode) +`; + const out = runPs(script); + const origin = (out.match(/ORIGIN=(\S*)/) || [])[1]; + const mode = (out.match(/MODE=(\S*)/) || [])[1]; + return { pref, origin, mode }; + } + + // (a) the CLI install: the terminal wins, and the click keeps toggling + let cli; + try { + cli = resolveClickTarget(sourcePath); + } catch (e) { + bad('the CLI click target is extractable', e.message); + } + if (cli) { + eq('the CLI install prefers the caller terminal', cli.pref, 'terminal'); + eq('the CLI install resolves the click to the terminal', cli.origin, 'caller'); + eq('the CLI install keeps the show/hide toggle', cli.mode, 'toggle'); + } + + // (b) the Marketplace package: the desktop app wins, and it is focused + if (!existsSync(packagePath)) { + skipped++; + console.log('[skip] the Marketplace package is not on this branch (the CLI install was checked)'); + } else { + let pkg; + try { + pkg = resolveClickTarget(packagePath); + } catch (e) { + bad('the package click target is extractable', e.message); + } + if (pkg) { + eq('the Marketplace package prefers the desktop app', pkg.pref, 'app'); + eq('the Marketplace package resolves the click to the app window', pkg.origin, 'desktop-app'); + eq('the Marketplace package focuses instead of hiding', pkg.mode, 'focus'); + } + } +} + // --------------------------------------------------------------------------- // summary // --------------------------------------------------------------------------- From 7eee41450a718d7584b1a4c910096078e69a6f3d Mon Sep 17 00:00:00 2001 From: antianqi Date: Fri, 23 Oct 2026 10:07:06 +0800 Subject: [PATCH 36/38] docs(mcode-island): the click description follows the CLI profile The source, the SKILL and the README all described the desktop order (app window first) because that is what the shared code used to do. It does not any more: the CLI install prefers the caller's terminal, and only falls back to the desktop app window when there is no caller window to go back to. The Marketplace build keeps the desktop order, and the source now says which build it is describing and why the two differ. --- plugins/antianqi/mcode-island/README.md | 38 +++++++++++-------- plugins/antianqi/mcode-island/skills/SKILL.md | 22 ++++++----- .../mcode-island/skills/mcode-island/SKILL.md | 22 ++++++----- 3 files changed, 49 insertions(+), 33 deletions(-) diff --git a/plugins/antianqi/mcode-island/README.md b/plugins/antianqi/mcode-island/README.md index c85338cd..7b21dda3 100644 --- a/plugins/antianqi/mcode-island/README.md +++ b/plugins/antianqi/mcode-island/README.md @@ -409,25 +409,33 @@ invoke `.ps1` more easily than `.cmd`. ## How click-to-focus works -The click target is resolved on every click, in this order: +The click target is resolved on every click. **This install prefers the +caller's terminal**, the way the CLI has always behaved: | # | target | how it is found | what the click does | | - | ------------------- | -------------------------------------- | -------------------------------- | | 1 | a manual pin | `pin.json` (written by `mcode-island pin`) | focuses that window | -| 2 | the desktop app | a process named `MiniMax Inside Code` / `MiniMax Code` / `Mavis` in the widget's own session | focuses the app window | -| 3 | the caller's terminal | `caller.json` (written by `notify-island.ps1`) | shows it, or hides it if it is already visible | - -**Why the desktop app wins over the terminal.** In the desktop app the agent -is hosted by the app and runs in a terminal the app opened. Hiding that -terminal does not stop the agent — it just leaves the user looking at a static -pill. "Take me back to the app window" is the action that matches the -intent, so the app window is focused and never hidden. - -**Why the pin still wins.** `mcode-island pin` stores the foreground window in -`pin.json`, a separate file from `caller.json`: `caller.json` is rewritten on -every status transition, so a pin stored there would not survive the next -push. The pin is the escape hatch for the case where the desktop app is open -next to a CLI session and the user wants the click to go to their terminal. +| 2 | the caller's terminal | `caller.json` (written by `notify-island.ps1`) | shows it, or hides it if it is already visible | +| 3 | the desktop app | a process named `MiniMax Inside Code` / `MiniMax Code` / `Mavis` in the widget's own session | focuses the app window (only when there is no caller window) | + +**The two builds prefer different targets, on purpose.** The CLI runtime +loads the `io.minimax.mcode` hooks, so the agent pushes the state itself and +the click keeps the CLI habit: one click hides the terminal, the next brings +it back. The Marketplace package ships no Hook material, the agent is hosted +by the desktop app, and there the terminal belongs to the app session — hiding +it leaves a static pill behind — so that build focuses the app window instead. + +Both are the same code: the source carries one switch, +`$script:clickPrefers`, and the Marketplace builder flips that exact line to +`'app'` (and fails the build when it cannot find it). The pin still wins in +both builds — an explicit `mcode-island pin` is the user's own choice and +outranks the preference. + +`mcode-island pin` stores the foreground window in `pin.json`, a separate file +from `caller.json`: `caller.json` is rewritten on every status transition, so +a pin stored there would not survive the next push. The pin is the escape +hatch for the case where the desktop app is open next to a CLI session and the +user wants the click to go to their terminal. When the auto-detected HWND is wrong (common on Windows Terminal with multiple tabs), run `mcode-island pin` from inside the tab you want to return diff --git a/plugins/antianqi/mcode-island/skills/SKILL.md b/plugins/antianqi/mcode-island/skills/SKILL.md index 918365be..39ad668c 100644 --- a/plugins/antianqi/mcode-island/skills/SKILL.md +++ b/plugins/antianqi/mcode-island/skills/SKILL.md @@ -30,13 +30,17 @@ Click the pill to go back to where the work happens. The target is resolved on every click, in this order: 1. **A manual pin** (`mcode-island pin`) — that window is focused. -2. **The desktop app window** (`MiniMax Inside Code` / `MiniMax Code` / - `Mavis`) — it is focused, never hidden. In the desktop app the agent runs - inside the app, so hiding the terminal behind it would leave the user - staring at a static pill while the agent keeps running. -3. **The originating terminal** (`caller.json`) — the original behaviour: one - click hides the terminal, the next brings it back maximized. - +2. **The originating terminal** (`caller.json`) — one click hides it, the next + brings it back maximized. This is the CLI habit, and this install keeps it. +3. **The desktop app window** (`MiniMax Inside Code` / `MiniMax Code` / + `Mavis`) — focused, never hidden, and only when there is no caller window + to go back to. + +The Marketplace build ships the other order: there the agent is hosted by the +desktop app, so hiding the terminal behind it would leave the user staring at +a static pill, and the click focuses the app window instead. Both builds are +the same code — the source carries one switch, `$script:clickPrefers`, and the +Marketplace builder flips it. Run `mcode-island pin` from inside a terminal to override the target explicitly (useful when the auto-detected HWND is wrong, e.g. Windows Terminal multi-tab, or when the desktop app is open next to a CLI session). @@ -150,8 +154,8 @@ deliberately avoids hard-coded paths so any user / any install location works. After each push (or after each hook fires), the widget on the user's primary display updates within ~400 ms (one polling cycle). On click, the widget -focuses the desktop app window (or the pinned window, or the originating -terminal tab — see the click-target order above). The widget is intentionally +focuses the pinned window, or the originating terminal tab (which it hides +and restores — see the click-target order above). The widget is intentionally hard to kill: Alt+F4 hides it, not closes it, and `mcode-island show` re-raises the hidden window in under 1 second. diff --git a/plugins/antianqi/mcode-island/skills/mcode-island/SKILL.md b/plugins/antianqi/mcode-island/skills/mcode-island/SKILL.md index 75f1696d..1d42796e 100644 --- a/plugins/antianqi/mcode-island/skills/mcode-island/SKILL.md +++ b/plugins/antianqi/mcode-island/skills/mcode-island/SKILL.md @@ -30,13 +30,17 @@ Click the pill to go back to where the work happens. The target is resolved on every click, in this order: 1. **A manual pin** (`mcode-island pin`) — that window is focused. -2. **The desktop app window** (`MiniMax Inside Code` / `MiniMax Code` / - `Mavis`) — it is focused, never hidden. In the desktop app the agent runs - inside the app, so hiding the terminal behind it would leave the user - staring at a static pill while the agent keeps running. -3. **The originating terminal** (`caller.json`) — the original behaviour: one - click hides the terminal, the next brings it back maximized. - +2. **The originating terminal** (`caller.json`) — one click hides it, the next + brings it back maximized. This is the CLI habit, and this install keeps it. +3. **The desktop app window** (`MiniMax Inside Code` / `MiniMax Code` / + `Mavis`) — focused, never hidden, and only when there is no caller window + to go back to. + +The Marketplace build ships the other order: there the agent is hosted by the +desktop app, so hiding the terminal behind it would leave the user staring at +a static pill, and the click focuses the app window instead. Both builds are +the same code — the source carries one switch, `$script:clickPrefers`, and the +Marketplace builder flips it. Run `mcode-island pin` from inside a terminal to override the target explicitly (useful when the auto-detected HWND is wrong, e.g. Windows Terminal multi-tab, or when the desktop app is open next to a CLI session). @@ -177,8 +181,8 @@ deliberately avoids hard-coded paths so any user / any install location works. After each push (or after each hook fires), the widget on the user's primary display updates within ~400 ms (one polling cycle). On click, the widget -focuses the desktop app window (or the pinned window, or the originating -terminal tab — see the click-target order above). The widget is intentionally +focuses the pinned window, or the originating terminal tab (which it hides +and restores — see the click-target order above). The widget is intentionally hard to kill: Alt+F4 hides it, not closes it, and `mcode-island show` re-raises the hidden window in under 1 second. From 85830fd667aefedccbdaad6cb12ee1355a18dde7 Mon Sep 17 00:00:00 2001 From: antianqi Date: Fri, 23 Oct 2026 14:54:22 +0800 Subject: [PATCH 37/38] ci(mcode-island): the token step asserts the encrypted contract, not the plaintext one The token handling changed: the token is stored DPAPI-encrypted in config.json as planApiTokenEnc, and the plaintext planApiToken field is migrated away on read. The local runner (scripts/test-windows-workflow-local.ps1) was updated with that change. The Windows workflow was not. Its step 2 still asserted `config.json planApiToken == FAKE_TOKEN`, so the job went red on the first push that carried the new token handling: set: config.json planApiToken mismatch (got: ) This step is the CI mirror of the local runner's step 2, so it now asserts the same thing: - config.json has no plaintext planApiToken - config.json has planApiTokenEnc - planApiTokenEnc decrypts back to the token - -Show reports "config.json planApiTokenEnc" with the masked prefix - -Clear removes both fields The point of the check is unchanged -- the token must land in the isolated APPDATA and come back out of it -- but it now goes through the same door the code uses. --- .github/workflows/mcode-island-windows.yml | 24 ++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/.github/workflows/mcode-island-windows.yml b/.github/workflows/mcode-island-windows.yml index 2046c281..6219df32 100644 --- a/.github/workflows/mcode-island-windows.yml +++ b/.github/workflows/mcode-island-windows.yml @@ -135,14 +135,27 @@ jobs: $cfgFile = Join-Path $apphome.FullName 'mcode-island\config.json' if (-not (Test-Path $cfgFile)) { throw "set: $cfgFile not written" } $cfg = Get-Content $cfgFile -Raw | ConvertFrom-Json - if ($cfg.planApiToken -ne $env:FAKE_TOKEN) { - throw "set: config.json planApiToken mismatch (got: $($cfg.planApiToken))" + # The token is stored DPAPI-encrypted (planApiTokenEnc), not as + # plaintext. Asserting the plaintext field here was a leftover + # from before the token handling changed; the local runner + # (scripts/test-windows-workflow-local.ps1) already asserts + # the encrypted contract, and this step is the CI mirror of it. + if ($cfg.PSObject.Properties['planApiToken']) { + throw "set: config.json still has a plaintext planApiToken" + } + if (-not $cfg.PSObject.Properties['planApiTokenEnc']) { + throw "set: config.json has no planApiTokenEnc" + } + . 'plugins/antianqi/mcode-island/scripts/lib/Protect-Token.ps1' + $decrypted = Unprotect-Token ([string]$cfg.planApiTokenEnc) + if ($decrypted -ne $env:FAKE_TOKEN) { + throw "set: planApiTokenEnc does not decrypt back to the token" } # 2b) show: verify it reports the config.json source + masked prefix $r2 = (& $set -Show | Out-String).Trim() - if ($r2 -notmatch 'config\.json planApiToken') { - throw "show after set: expected 'config.json planApiToken', got: $r2" + if ($r2 -notmatch 'config\.json planApiTokenEnc') { + throw "show after set: expected 'config.json planApiTokenEnc', got: $r2" } $expectedMask = ($env:FAKE_TOKEN).Substring(0, [Math]::Min(10, $env:FAKE_TOKEN.Length)) + '\.\.\.' if ($r2 -notmatch $expectedMask) { @@ -153,6 +166,9 @@ jobs: $r3 = (& $set -Clear | Out-String).Trim() if ($r3 -notmatch '已从 config\.json 删除') { throw "clear: expected '已从 config.json 删除', got: $r3" } $cfgAfter = Get-Content $cfgFile -Raw | ConvertFrom-Json + if ($cfgAfter.PSObject.Properties['planApiTokenEnc']) { + throw "clear: planApiTokenEnc still present in config.json" + } if ($cfgAfter.PSObject.Properties['planApiToken']) { throw "clear: planApiToken still present in config.json" } From d3115f375a934198eb6593776e5167467b2cab50 Mon Sep 17 00:00:00 2001 From: antianqi Date: Fri, 23 Oct 2026 15:57:56 +0800 Subject: [PATCH 38/38] fix(mcode-island): the 5h usage survives a tick instead of blinking out The pill's 5h badge was blank almost all the time, while the detector's own log said "5h usage refreshed: remaining=94%". The number was arriving, and being dropped again on the very next write. Mechanism, confirmed against the live status.json with a 25ms watcher: - The detector's step 4 calls Get-PlanUsage on every tick, to prefer the plan usage the MiniMax Code runtime already wrote to disk. - Get-PlanUsage cleared $script:plan5hRemainingPct and $script:plan5hResetMs on the way in, before it went looking for a file. - The only thing that refills them is Refresh-5hUsage, whose 60s TTL gate returns early WITHOUT touching them. So on every tick except the one right after the API call, the cached value was $null, and the write path emitted usage5h: null. The number survived exactly two writes -- the refresh itself and the state inference right after it -- and was gone for the remaining ~58 seconds of every minute. Measured on the widget's own poll log: 4 of 28 polls carried a value in the 15:40-15:42 window, 24 of 24 carry one after the fix. The fix is in the lib, not in the detector. Get-PlanUsage is a SOURCE. A source with nothing to offer has to leave the current value alone -- that is what the credentialed path already does when its TTL gate blocks a refill, so the two paths now behave the same way. The detector's call order does not change, and no write path changes. The latent bug shipped with fbdfd53, but the detector that calls Get-PlanUsage only reached this machine at 13:50 today, so the badge was healthy until the 13:58 restart and has been broken since. Validation - scripts/test-substep-progress.mjs section 11. 11a is the unit half: the lib must not wipe a value it has no replacement for. 11b is the end-to-end half: two detector ticks against a real status.json, the second write still carrying the number. - The suite on this branch: 130 pass, 0 fail, 1 skipped (platform) on Windows PowerShell 5.1, which is what CI runs. - Negative injection: putting the two clearing lines back makes exactly 4 checks fail -- 11a's two and 11b's two -- while "both ticks wrote" and "the second write carried the state" stay green. The writes really happen; only the number goes missing, which is the production symptom. 126 pass / 4 fail with the injection, 130 pass / 0 fail after the revert. - Live: the lib was redeployed, the detector restarted, and a 25ms watcher over status.json recorded 23 consecutive writes, every one of them carrying usage5h=90. The reset countdown now advances at the 60s refresh and holds its value in between, instead of being blanked for a minute at a time. - The pill itself renders "4h04m 90%" now. Test evidence - node scripts/test-substep-progress.mjs -> 130 pass, 0 fail, 1 skipped - negative injection -> 126 pass, 4 fail (reverted afterwards) Design compliance - No credentials, no network, no telemetry, no third-party service: the fix touches no token handling and makes no request. It only decides whether a number that has already been fetched survives the next tick. - The runtime-usage path stays preferred: when the runtime file is there, its numbers still win; the credentialed fallback still runs when it is not. - The detector's write semantics are untouched: -MetadataRefresh still restates the state, the state inference still claims it. - ASCII only, no BOM, LF line endings -- the lib is dot-sourced by a Windows PowerShell 5.1 detector on a zh-CN host, where a BOM-less script with non-ASCII would not even parse. --- .../mcode-island/scripts/lib/Plan-Usage.ps1 | 35 ++++- .../scripts/test-substep-progress.mjs | 131 ++++++++++++++++++ 2 files changed, 161 insertions(+), 5 deletions(-) diff --git a/plugins/antianqi/mcode-island/scripts/lib/Plan-Usage.ps1 b/plugins/antianqi/mcode-island/scripts/lib/Plan-Usage.ps1 index 6120e2bd..0cf32bec 100644 --- a/plugins/antianqi/mcode-island/scripts/lib/Plan-Usage.ps1 +++ b/plugins/antianqi/mcode-island/scripts/lib/Plan-Usage.ps1 @@ -31,9 +31,31 @@ # # # Behaviour # -# Sets $script:plan5hRemainingPct (0..100, or $null when unknown) and -# $script:plan5hResetMs (milliseconds until the window resets, relative). -# Returns $true when a usable file was read. +# Sets $script:plan5hRemainingPct (0..100) and $script:plan5hResetMs +# (milliseconds until the window resets, relative) when a usable file was +# read. Returns $true when a usable file was read. +# +# It does NOT clear those values when it finds nothing. +# +# This function is a SOURCE. The caller owns the cached value, and the +# detector calls this on every tick. The credentialed fallback +# (Refresh-5hUsage, in Get-5hUsage.ps1) is TTL-gated: it refills the +# values once a minute and returns early otherwise. +# +# When this lib cleared the values on the way in, the number survived +# exactly two writes -- the refresh itself and the state inference right +# after it -- and was $null for the remaining ~58 seconds of every +# minute. The pill showed a blank 5h badge almost all the time while +# the detector's own log said "5h usage refreshed: remaining=94%", +# which is why it looked like the data arrived and was then lost +# somewhere else. +# +# A source with nothing to offer has to leave the current value alone. +# That is what the credentialed path already does when its TTL gate +# blocks a refill, so the two paths now behave the same way. +# +# $script:plan5hFromRuntime still flips to $false when no usable file +# was read, so the caller knows to fall back to the credentialed path. $script:planUsageCandidates = @( $env:MCODE_PLAN_USAGE_FILE @@ -42,8 +64,11 @@ $script:planUsageCandidates = @( ) function Get-PlanUsage { - $script:plan5hRemainingPct = $null - $script:plan5hResetMs = $null + # The cached percentage and reset countdown are deliberately left + # alone here. Only `plan5hFromRuntime` is reset, because it is this + # function's own answer to "did a usable file turn up?", and the + # caller needs that answer to decide whether to fall back to the + # credentialed path. $script:plan5hFromRuntime = $false foreach ($candidate in $script:planUsageCandidates) { diff --git a/plugins/antianqi/mcode-island/scripts/test-substep-progress.mjs b/plugins/antianqi/mcode-island/scripts/test-substep-progress.mjs index c300fd8d..ecb6ab8e 100644 --- a/plugins/antianqi/mcode-island/scripts/test-substep-progress.mjs +++ b/plugins/antianqi/mcode-island/scripts/test-substep-progress.mjs @@ -1270,6 +1270,137 @@ Write-Output ("ORIGIN=" + $t.Origin + " MODE=" + $t.Mode) } } +// --------------------------------------------------------------------------- +// 11. The 5h usage survives a tick (the blank-badge regression) +// --------------------------------------------------------------------------- +// +// Symptom: the pill's 5h badge was blank almost all the time, while the +// detector's own log said "5h usage refreshed: remaining=94%". The +// number WAS arriving -- and was being dropped again on the very next +// write. +// +// Mechanism, confirmed against the live status.json with a 25ms +// watcher: the detector's step 4 calls Get-PlanUsage on every tick, and +// that lib used to clear $script:plan5hRemainingPct / +// $script:plan5hResetMs on the way in. The only thing that refills them +// is Refresh-5hUsage, whose 60s TTL gate returns early WITHOUT +// touching them. So the value survived exactly two writes -- the +// refresh itself and the state inference right after it -- and was +// $null for the remaining ~58 seconds of every minute. +// +// The contract pinned here: a value that has been fetched must still be +// on the next write, even though no runtime usage file exists and the +// credentialed refill is TTL-blocked. +// +// 11a is the unit half: the lib must not wipe a value it has no +// replacement for. 11b is the end-to-end half: two detector ticks +// against a real status.json, the second write still carrying it. +// +// 11b needs TWO ticks on purpose. The regression was invisible on the +// first tick -- the refresh wrote the number, and the write right +// after it still had it. The number only went missing on the tick +// AFTER the one that cleared the cache, so a single-tick test would +// have passed against the broken code. +if (PS_BIN) { + const planUsageLibRef = join(PLUGIN_ROOT, 'scripts', 'lib', 'Plan-Usage.ps1'); + const get5hLibRef = join(PLUGIN_ROOT, 'scripts', 'lib', 'Get-5hUsage.ps1'); + const detectorSrc = readFileSync(join(PLUGIN_ROOT, 'mcode-status-detect.ps1'), 'utf8'); + + // The environment the usage lib resolves its candidates from. + // APPDATA and USERPROFILE are both set because the lib builds that + // candidate list with Join-Path at load time, and Join-Path throws + // on an empty path -- which is exactly what a Linux CI runner + // hands it. MCODE_PLAN_USAGE_FILE is pointed at a path that does + // not exist, so the "there is no runtime usage file" precondition + // cannot be broken by whatever the developer's own machine has. + const usageEnv = (appdata) => ({ + APPDATA: appdata, + USERPROFILE: appdata, + MCODE_PLAN_USAGE_FILE: join(appdata, 'no-such-usage.json'), + }); + + // --- 11a: the lib must not clear a value it cannot replace ------ + { + const appdata = makeIsolatedAppData('plan-usage'); + const out = runPs(` +$ErrorActionPreference = 'Stop' +. '${planUsageLibRef}' +$script:plan5hRemainingPct = 94 +$script:plan5hResetMs = 15496790 +$ret = Get-PlanUsage +"PCT=$($script:plan5hRemainingPct)" +"MS=$($script:plan5hResetMs)" +"FROMRUNTIME=$($script:plan5hFromRuntime)" +"RET=$ret" +`, usageEnv(appdata)); + + check('the usage lib found no runtime file', + /FROMRUNTIME=False/.test(out) && /RET=False/.test(out), + `expected FROMRUNTIME=False and RET=False, got ${JSON.stringify(out.trim())}`); + check('the usage lib did not wipe the cached percentage', + /PCT=94(\s|$)/m.test(out), + `the cached percentage was cleared: ${JSON.stringify(out.trim())}`); + check('the usage lib did not wipe the cached reset countdown', + /MS=15496790(\s|$)/m.test(out), + `the cached reset countdown was cleared: ${JSON.stringify(out.trim())}`); + } + + // --- 11b: two detector ticks, the number still there ----------- + { + const appdata = makeIsolatedAppData('usage-survives'); + const statusPath = join(appdata, 'mcode-island', 'status.json'); + + // The detector's step 4, in the order the real loop runs it. + const step4 = ` +$prevPct = $script:plan5hRemainingPct +$prevMs = $script:plan5hResetMs +Get-PlanUsage | Out-Null +if (-not $script:plan5hFromRuntime -and $script:plan5hAvailable) { Refresh-5hUsage } +$curPct = $script:plan5hRemainingPct +$curMs = $script:plan5hResetMs +`; + + const harness = ` +$ErrorActionPreference = 'Stop' +. '${storeLibRef}' +. '${planUsageLibRef}' +. '${get5hLibRef}' +$S_IDLE = 'idle' +$S_DETECTOR = 'detector' +$script:plan5hTodoData = $null +$script:plan5hAvailable = $true +# What the credentialed fetch produced a moment ago. +$script:plan5hRemainingPct = 94 +$script:plan5hResetMs = 15496790 +# ... and the TTL gate that keeps the next refill from happening. +$script:plan5hLastCallAt = Get-Date +${extractFn(detectorSrc, 'Write-Status')} +${extractFn(detectorSrc, 'Write-StatusUnderLock')} +# tick 1: the state inference writes, then step 4 refreshes. +Write-Status 'working' 'Reading' 'read' +${step4} +# tick 2: the state inference writes again. The number must still be +# there -- step 4 found no runtime file and the credentialed refill was +# TTL-blocked, so nothing replaced it, and nothing may have wiped it. +Write-Status 'done' 'Read' 'read' +'DONE' +`; + const out = runPs(harness, usageEnv(appdata)); + check('the two-tick harness ran to completion', + /DONE/.test(out) && !/Exception/i.test(out), + `harness did not complete: ${JSON.stringify(out.trim())}`); + + const final = readJsonFile(statusPath); + eq('the 5h usage survived the tick that had no runtime file', final.usage5h, 94); + eq('the 5h reset countdown survived too', final.usage5hResetMs, 15496790); + // Both writes have to have happened. Otherwise "the number is + // still 94" could just be the FIRST write's payload, with the + // second write never landing at all. + eq('both ticks wrote', final.version, 2); + eq('the second write carried the state', final.state, 'done'); + } +} + // --------------------------------------------------------------------------- // summary // ---------------------------------------------------------------------------