diff --git a/.github/workflows/mcode-island-windows.yml b/.github/workflows/mcode-island-windows.yml index 2445f47d..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" } @@ -411,3 +427,56 @@ jobs: } Write-Host "test c (no token): OK returned null" Write-Host "Get-5hUsage via dot-source + matching fixture + token-source precedence: 3/3 OK" + + # 5) Behavioral sub-step + redaction suite (round-19 review #5). + # + # scripts/test-substep-progress.mjs existed but no workflow ran it, so + # its 20 assertions never gated anything. It is the only suite that + # executes the PowerShell under test rather than grepping for + # substrings, and it carries the secret canary for review item #1, so + # leaving it unwired meant a redaction regression could land silently. + # + # The suite resolves its own PowerShell: it uses $env:PS_BIN if set, + # otherwise the value if it is an existing path, otherwise it probes + # pwsh / powershell.exe / powershell on PATH and SKIPS the behavioral + # checks loudly if none is found. It never assumes a + # user-specific absolute path. + # + # Negative-injection: restore the `$detail = "$act '$txt'"` line in + # _lib.ps1, or drop SWP_NOMOVE from Toggle-CallerWindow, and this step + # fails on the canary / the flag assertion respectively. + - name: Behavioral sub-step + redaction suite (round-19 requirement #5) + run: | + [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + $OutputEncoding = [System.Text.Encoding]::UTF8 + try { chcp 65001 | Out-Null } catch {} + + # Report which PowerShell the suite will use. Not a hard requirement: + # the suite does its own resolution and falls back to PATH. + $candidates = @('pwsh', 'powershell.exe', 'powershell') + $chosen = $null + foreach ($c in $candidates) { + if (Get-Command $c -ErrorAction SilentlyContinue) { $chosen = $c; break } + } + if ($chosen) { + Write-Host "PowerShell on PATH: $chosen ($((& $chosen -NoProfile -Command '$PSVersionTable.PSVersion.ToString()')))" + } else { + Write-Host "No PowerShell found on PATH; the suite will fail loudly rather than skip" + } + + # Resolve the suite path against the repo root instead of relying on + # the working directory. GitHub Actions runs every `run:` block from + # $GITHUB_WORKSPACE, not from the plugin directory, so a bare + # `scripts/...` path resolves to the wrong place and node exits + # MODULE_NOT_FOUND. It passed locally only because the local shell + # happened to be sitting in the plugin directory. + $suite = 'plugins/antianqi/mcode-island/scripts/test-substep-progress.mjs' + if (-not (Test-Path $suite)) { + throw "$suite not found (cwd: $((Get-Location).Path))" + } + Write-Host "Suite: $((Resolve-Path $suite).Path)" + + node $suite + if ($LASTEXITCODE -ne 0) { + throw "test-substep-progress.mjs failed with exit code $LASTEXITCODE" + } diff --git a/plugins/antianqi/mcode-island/.gitattributes b/plugins/antianqi/mcode-island/.gitattributes index 81407448..ef9c5280 100644 --- a/plugins/antianqi/mcode-island/.gitattributes +++ b/plugins/antianqi/mcode-island/.gitattributes @@ -1,7 +1,25 @@ -# Force LF for all source files in this plugin. PowerShell 5.1 reads -# CRLF fine, but a cross-platform smoke (e.g. Linux CI) sees LF and -# the pre-existing CRLF-handling bug in scripts/validate.mjs trips -# on Windows-checked-out CRLF. LF avoids both failure modes. +# Force LF for all source files in this plugin, so a cross-platform +# smoke (e.g. Linux CI) does not trip the pre-existing CRLF-handling bug +# in scripts/validate.mjs on a Windows-checked-out tree. +# +# IMPORTANT: this policy applies to .mjs / .json / .md, and it is safe +# for .ps1 ONLY because every .ps1 that contains non-ASCII also carries +# a UTF-8 BOM (enforced by smoke check 11). +# +# Round-20 correction: the comment here used to say "PowerShell 5.1 reads +# CRLF fine, so LF avoids both failure modes". That is backwards, and it +# is what hid a real outage. Windows PowerShell 5.1 decodes a BOM-less +# script using the system ANSI codepage, so an LF .ps1 containing +# non-ASCII comments is mis-decoded and the C# here-string in +# mcode-status-detect.ps1 stops being a here-string -- `using System;` +# gets parsed as PowerShell and the detector dies before its first +# statement. Verified against 5.1: both CRLF and a UTF-8 BOM fix the +# parse, bare LF does not. The BOM is used rather than flipping this file +# to CRLF precisely so the Linux-side validator stays protected. +# +# Do NOT "clean up" the BOMs. If you rewrite a .ps1 with an editor that +# drops them, smoke check 11 will fail and any `Copy-Item` sync into the +# install directory will install a dead detector. # # Override at clone time: `git config core.autocrlf input` for a # one-shot pull, or set `[core] autocrlf = false` globally. diff --git a/plugins/antianqi/mcode-island/README.md b/plugins/antianqi/mcode-island/README.md index 0f39645d..7b21dda3 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. @@ -90,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 @@ -156,6 +187,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/` @@ -194,7 +247,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 @@ -232,7 +285,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 | @@ -241,33 +294,62 @@ 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` -> `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 -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 } +} +``` -1. A token is configured (env `MINIMAX_OAUTH_TOKEN` or `MINIMAX_API_KEY`, - or `set-token.ps1 ` which writes to `config.json:planApiToken`). +`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 + (`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). 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 @@ -287,16 +369,27 @@ 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.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. +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 @@ -304,8 +397,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 ``` @@ -316,15 +409,38 @@ 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. - -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. +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 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 +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 @@ -343,6 +459,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/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/io.minimax.mcode/hooks/scripts/_lib.ps1 b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/_lib.ps1 index 797813ba..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 @@ -1,4 +1,4 @@ -# mcode-island: shared library for io.minimax.mcode Hooks scripts. +# mcode-island: shared library for io.minimax.mcode Hooks scripts. # Loaded via dot-source at the top of each event script: # . "$PSScriptRoot\_lib.ps1" # All event scripts under this directory MUST exit 0 (or 2 with a stderr @@ -12,6 +12,10 @@ $ErrorActionPreference = 'Stop' $script:PluginRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..\..')).Path $script:NotifyIsland = Join-Path $script:PluginRoot 'notify-island.ps1' +# Shared redaction helper (round-20). Lives in a lib because the detector +# is a second, independent producer of the same text and must redact too. +. (Join-Path $script:PluginRoot 'scripts\lib\Protect-Text.ps1') + function Set-ConsoleUtf8 { # Force UTF-8 so the PowerShell child that mcode spawns reads the # stdin JSON cleanly. notify-island.ps1 also does this internally, @@ -43,7 +47,10 @@ function Push-Island { [ValidateSet('idle','thinking','working','waiting','done','error')] [string]$State, - [string]$Message = '' + [string]$Message = '', + [int]$Step = -1, + [int]$Total = -1, + [string]$Detail = '' ) if (-not (Test-Path -LiteralPath $script:NotifyIsland)) { # Widget is not installed yet — silent no-op. The plugin's @@ -52,7 +59,7 @@ function Push-Island { return } try { - & $script:NotifyIsland -State $State -Message $Message 2>$null | Out-Null + & $script:NotifyIsland -State $State -Message $Message -Step $Step -Total $Total -Detail $Detail 2>$null | Out-Null } catch { # Hook must never block the agent on a notification failure. } @@ -78,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. @@ -97,10 +151,47 @@ function Format-ToolSummary { 'WebSearch' { $detail = [string]$Event.tool_input.query } 'Task' { $detail = [string]$Event.tool_input.description } 'NotebookEdit' { $detail = [string]$Event.tool_input.notebook_path } + # mcode-internal: Computer Use 抽 action + coordinate。 + # 例: "mcode-computer-use : click at (1024,768)" + # 注意:coordinate 是 array,PowerShell 默认 $OFS=' ' 会让 + # "$coord" 渲染成 "(1024 768)" 不是 "(1024,768)"。必须 + # 显式 -join ','。' ' 在 pill 上看起来像数字被截断, + # 影响用户判断坐标。 + # + # SECURITY: `tool_input.text` is whatever the user typed. It + # reaches this function verbatim, and its output is written to + # status.json, the append-only island.log, and rendered on an + # always-on-top pill. That surface is shared-screen visible, so + # any password / token / verification code typed through Computer + # Use ends up in a screenshot, a screen share, or a screen + # recording. Never echo the raw text. Report the action and a + # length only, so the pill still answers "is the agent typing?" + # without carrying the secret. + 'mcode-computer-use' { + $act = if ($Event.tool_input.action) { [string]$Event.tool_input.action } else { '' } + if ($Event.tool_input.coordinate) { + $coord = $Event.tool_input.coordinate + $coordStr = "($($coord -join ','))" + $detail = "$act at $coordStr" + } elseif ($Event.tool_input.text) { + # Length only. No substring, no length bucketing that + # could leak content shape, no echo of the value. + $len = ([string]$Event.tool_input.text).Length + $detail = "$act <$len chars, redacted>" + } else { + $detail = $act + } + } default { $detail = '' } } } if ([string]::IsNullOrEmpty($detail)) { return $tool } + # Redact before collapsing/truncating (round-20). This is the single + # choke point for every tool branch above: the result is written to + # status.json, appended to island.log, AND rendered on the pill, so + # redacting here covers all three sinks at once. Without it a Bash + # command like `export API_KEY=sk-...` reached all three verbatim. + $detail = Protect-SecretText $detail # Collapse newlines, take first 80 chars. $detail = ($detail -replace "[\r\n]+", ' ').Trim() if ($detail.Length -gt 80) { $detail = $detail.Substring(0, 77) + '...' } diff --git a/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/post-tool-use.ps1 b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/post-tool-use.ps1 index 034a31e5..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,28 +1,40 @@ -# Hook: PostToolUse +# Hook: PostToolUse # Event: io.minimax.mcode / PostToolUse # State: done / error -# Note: Fires after every tool call returns. Heuristic: if the -# tool_result is empty or matches an error pattern, push -# error; otherwise push done. Self-push calls are filtered. +# 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". . "$PSScriptRoot\_lib.ps1" $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 +$isError = Test-ToolResultError $evt.tool_result -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 } +# Format-ToolSummary 抽 detail,但要剥掉 "tool : " 前缀,只留后半段 +$summary = Format-ToolSummary $evt +$detail = '' +if ($summary -and $summary.StartsWith("$tool : ")) { + $detail = $summary.Substring($tool.Length + 3) +} elseif ($summary -and $summary -ne $tool) { + $detail = $summary } +# Build-DisplayMessage 只在 Step > 0 时才用 detail(Step<=0 直接返回 +# Message),所以只传 -Detail 的话 detail 永远不显示 +#(round-19 review hetaoBackend #4)。这里给一个确定的 Step/Total, +# 渲染成 "step 1/1 · "。 +# PostToolUse 是单次工具结果,不参与多步序列,所以用 1/1 而不是 +# 猜一个更大的分母——猜错会让 pill 显示 "step 1/0"。 +$step = 1 +$total = 1 + if ($isError) { - Push-Island -State error -Message "$tool failed" + Push-Island -State error -Message "$tool failed" -Detail $detail -Step $step -Total $total } else { - Push-Island -State done -Message "$tool ok" + Push-Island -State done -Message "$tool ok" -Detail $detail -Step $step -Total $total } exit 0 diff --git a/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/pre-compact.ps1 b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/pre-compact.ps1 index 8e4460fb..e1b8b2df 100644 --- a/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/pre-compact.ps1 +++ b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/pre-compact.ps1 @@ -1,4 +1,4 @@ -# Hook: PreCompact +# Hook: PreCompact # Event: io.minimax.mcode / PreCompact # State: thinking # Note: Fires before the runtime compresses context. We push diff --git a/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/session-end.ps1 b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/session-end.ps1 index 214f5629..c82be00a 100644 --- a/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/session-end.ps1 +++ b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/session-end.ps1 @@ -1,4 +1,4 @@ -# Hook: SessionEnd +# Hook: SessionEnd # Event: io.minimax.mcode / SessionEnd # State: idle # Note: Fires when the runtime terminates a session. We push idle diff --git a/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/stop.ps1 b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/stop.ps1 index 0637c436..c75d7026 100644 --- a/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/stop.ps1 +++ b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/stop.ps1 @@ -1,4 +1,4 @@ -# Hook: Stop +# Hook: Stop # Event: io.minimax.mcode / Stop # State: done # Note: Fires when the agent finishes a turn (one model response, diff --git a/plugins/antianqi/mcode-island/mcode-island.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 898322a7..6c05cab0 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)" @@ -73,9 +98,63 @@ 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; + public RECT rcMonitor; + public RECT rcWork; + public uint dwFlags; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] public string szDevice; + } public delegate bool EnumProc(IntPtr hWnd, IntPtr lParam); public static readonly IntPtr HWND_TOPMOST = new IntPtr(-1); + public static readonly IntPtr HWND_TOP = new IntPtr(0); public const uint SWP_NOACTIVATE = 0x0010; + public const uint SWP_NOZORDER = 0x0004; + public const uint SWP_NOSIZE = 0x0001; + public const uint SWP_NOMOVE = 0x0002; + public const uint MONITOR_DEFAULTTONEAREST = 0x00000002; + 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) { + var bad = new RECT { Left = -1, Top = -1, Right = -1, Bottom = -1 }; + IntPtr hMon = MonitorFromWindow(hWnd, MONITOR_DEFAULTTONEAREST); + if (hMon == IntPtr.Zero) return bad; + var mi = new MONITORINFO(); + mi.cbSize = System.Runtime.InteropServices.Marshal.SizeOf(mi); + if (!GetMonitorInfoW(hMon, ref mi)) return bad; + return mi.rcWork; + } // 找 pid 的第一个可见窗口 public static IntPtr FindVisibleWindowForPid(uint targetPid) { @@ -259,6 +338,23 @@ $stateMap = @{ error = @{ dot='#FFEF4444'; ring='#FFEF4444'; label='mcode · 出错'; icon='✕' } } +# 工具家族配色:working 态下用 family 色盖掉 state 色,一眼分辨"在读"和"在改"。 +# 只覆盖 dot/ring(视觉识别),不动 stateText 文案。 +# family 名与 detector 的 $TOOL_FAMILIES 值一一对应;缺 family 时回落到 state 色。 +# +# 色相分布刻意拉开:绿(shell) / 蓝(read) / 黄(write) / 紫(search) / 青(task) / +# 粉(web) / 灰蓝(plan)。plan 早先用的是 #8B5CF6,和 search 的 #A855F7 在深色 +# pill 上几乎分不开,换成去饱和的灰蓝。 +$familyMap = @{ + shell = '#FF22C55E' # bash / shell → 绿 + read = '#FF3B82F6' # read → 蓝 + write = '#FFEAB308' # edit / write → 黄 + search = '#FFA855F7' # grep / glob → 紫 + task = '#FF06B6D4' # task → 青 + web = '#FFEC4899' # web_search/fetch → 粉 + plan = '#FF94A3B8' # todowrite → 灰蓝 +} + # 颜色转 brush function C($hex) { return (New-Object System.Windows.Media.SolidColorBrush([System.Windows.Media.ColorConverter]::ConvertFromString($hex))) } @@ -370,6 +466,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 +501,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 +518,26 @@ function Update-State { [int]$Progress = -1, [int]$Usage5h = -2, [int]$Usage5hResetMs = 0, - [int]$TodoProgress = -2 + [int]$TodoProgress = -2, + [int]$Step = -1, + [int]$Total = -1, + [string]$Detail = '', + [string]$Family = '' ) $s = $script:stateMap[$State] if (!$s) { $s = $script:stateMap['idle'] } - $script:statusDot.Fill = C $s.dot - $script:pulseRing.Fill = C $s.ring + # 家族色只作用于 active 态(thinking/working/waiting),这样 done 绿 / error 红 + # 依然是"结果"语义,家族色只用来区分"在做什么"。 + $dotHex = $s.dot + $ringHex = $s.ring + if ($Family -and $State -in @('thinking','working','waiting')) { + $famHex = $script:familyMap[$Family] + if ($famHex) { $dotHex = $famHex; $ringHex = $famHex } + } + $script:statusDot.Fill = C $dotHex + $script:pulseRing.Fill = C $ringHex $script:stateText.Text = $s.label - $script:messageText.Text = if ($Message) { $Message } else { '' } + $script:messageText.Text = Build-DisplayMessage -Message $Message -Step $Step -Total $Total -Detail $Detail $script:actionIcon.Text = $s.icon if ($State -in @('thinking','working','waiting')) { Start-Pulse } else { Stop-Pulse } @@ -447,20 +591,20 @@ function Update-State { $script:progressFill.Visibility = 'Visible' $script:progressIndeterminate.Visibility = 'Collapsed' $script:progressScale.ScaleX = $clamped / 100.0 - $script:progressFill.Background = C $s.dot + $script:progressFill.Background = C $dotHex Stop-IndeterminateShimmer } elseif ($isActive -and $hasTodoProgress) { $script:progressBar.Visibility = 'Visible' $script:progressFill.Visibility = 'Visible' $script:progressIndeterminate.Visibility = 'Collapsed' $script:progressScale.ScaleX = $todoClamped / 100.0 - $script:progressFill.Background = C $s.dot + $script:progressFill.Background = C $dotHex Stop-IndeterminateShimmer } elseif ($isActive) { $script:progressBar.Visibility = 'Visible' $script:progressFill.Visibility = 'Collapsed' $script:progressIndeterminate.Visibility = 'Visible' - $script:progressShimmer.Fill = C $s.dot + $script:progressShimmer.Fill = C $dotHex $script:progressScale.ScaleX = 0 Start-IndeterminateShimmer } else { @@ -477,86 +621,315 @@ 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 $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 "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,失败就用第一个可见窗口 + $targetSource = 'parent-fallback' 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 "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 } +} + +# 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 } } - Dbg "FOCUS: re-resolved to hwnd $hwnd" } + # 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 +} + +# 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 -- 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 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 +} + +# 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 { - # 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]$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 } - # 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) + # 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): +# SW_MINIMIZE 在某些终端配置下(Windows Terminal "Always show tabs on top") +# 会保留一个 thin tab-bar strip 浮在桌面顶部,不算真"藏"。 +# show 分支先 SW_MAXIMIZE (激活+最大化),再用 SetWindowPos 强制拉到 +# MonitorFromWindow+GetMonitorInfo 拿到的真实 work area(2560x1392 而 +# 不是 [Screen]::PrimaryScreen 报告的 1920x1080 — WinForms DPI 虚拟化 +# 会把 2560x1440 物理像素报成 1920x1080 逻辑像素,SW_MAXIMIZE 跟着 +# 1920x1080 走,结果 WT 只填了物理显示器的左上 75%)。 +# 最后 SetWindowPos(HWND_TOP) + BringWindowToTop 抢 z-order,绕过 +# widget PID 没有 foreground 权限的限制。 +# 状态判定: IsWindowVisible 在 SW_HIDE 和 SW_MINIMIZE 后都返回 false +# (区别是 IsIconic:SW_HIDE 后 false,SW_MINIMIZE 后 true)。toggle 只看 +# IsWindowVisible 即可,SW_MAXIMIZE 在内部正确处理两种 case。 +function Toggle-Window { + param([Parameter(Mandatory = $true)]$Target) + + try { + $isShown = [WinAPI]::IsWindowVisible($Target.Hwnd) + if ($isShown) { + [WinAPI]::ShowWindow($Target.Hwnd, 0) | Out-Null # SW_HIDE + Dbg "TOGGLE: hid target=$($Target.Exe) PID=$($Target.Pid) hwnd=$($Target.Hwnd)" + } else { + # 1) SW_MAXIMIZE 激活+标记 maximized + [WinAPI]::ShowWindow($Target.Hwnd, 3) | Out-Null # SW_MAXIMIZE + # 2) SetWindowPos 强制拉到 monitor work area (绕过 DPI/remembered-size 限制) + $wa = [WinAPI]::GetWorkAreaForWindow($Target.Hwnd) + if ($wa.Left -ne -1) { + $cx = $wa.Right - $wa.Left + $cy = $wa.Bottom - $wa.Top + [WinAPI]::SetWindowPos($Target.Hwnd, [IntPtr]::Zero, $wa.Left, $wa.Top, $cx, $cy, [WinAPI]::SWP_NOZORDER) | Out-Null + # 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", + # 触发 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]$Target.Pid) | Out-Null + $nofollow = [WinAPI]::SWP_NOACTIVATE -bor [WinAPI]::SWP_NOSIZE -bor [WinAPI]::SWP_NOMOVE + [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 @" @@ -575,7 +948,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 @@ -586,7 +961,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) } @@ -609,6 +984,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) { @@ -629,31 +1048,96 @@ $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' Flash-Click - Focus-CallerWindow + Invoke-ClickTarget } } catch { Dbg "CLICK FAIL: $($_.Exception.Message)" @@ -670,6 +1154,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 {} @@ -677,6 +1163,7 @@ $timer.Add_Tick({ $window.Show() $window.WindowState = [System.Windows.WindowState]::Normal $window.Activate() + Assert-Topmost -Force Dbg 'SHOWN via signal file' }) } @@ -689,18 +1176,28 @@ $timer.Add_Tick({ $script:lastStatusMtime = $mtime $data = Get-Content $statusFile -Raw -Encoding UTF8 | ConvertFrom-Json # progress 也要进 sig,否则 agent 连续推 working+相同 message+不同 progress 会被去重 + # step/total/detail 也要进 sig,否则连续推同 state 但不同 step 会被去重 $prog = if ($data.PSObject.Properties['progress']) { [int]$data.progress } else { -1 } $usage = $null $resetMs = 0 $todoP = -2 + $step = -1 + $total = -1 + $detail = '' if ($data.PSObject.Properties['usage5h'] -and $null -ne $data.usage5h) { $usage = [int]$data.usage5h } if ($data.PSObject.Properties['usage5hResetMs'] -and $null -ne $data.usage5hResetMs) { $resetMs = [int]$data.usage5hResetMs } if ($data.PSObject.Properties['todoProgress'] -and $null -ne $data.todoProgress) { $todoP = [int]$data.todoProgress } - $sig = "$($data.state)|$($data.message)|$prog|$usage|$resetMs|$todoP|$($data.ts)" + if ($data.PSObject.Properties['step'] -and $null -ne $data.step) { $step = [int]$data.step } + if ($data.PSObject.Properties['total'] -and $null -ne $data.total) { $total = [int]$data.total } + if ($data.PSObject.Properties['detail'] -and $null -ne $data.detail) { $detail = [string]$data.detail } + # family 也进 sig,否则同一工具连续 working 但换了家族不会重新上色 + $family = '' + if ($data.PSObject.Properties['family'] -and $data.family) { $family = [string]$data.family } + $sig = "$($data.state)|$($data.message)|$family|$prog|$usage|$resetMs|$todoP|$step|$total|$detail|$($data.ts)" if ($sig -eq $script:lastStatusSig) { return } $script:lastStatusSig = $sig - Dbg "POLL: $($data.state) :: $($data.message) (progress=$prog usage5h=$usage resetMs=$resetMs todoProgress=$todoP)" - Update-State -State $data.state -Message $data.message -Progress $prog -Usage5h $usage -Usage5hResetMs $resetMs -TodoProgress $todoP + Dbg "POLL: $($data.state) :: $($data.message) family=$family step=$step/$total detail=$detail (progress=$prog usage5h=$usage resetMs=$resetMs todoProgress=$todoP)" + Update-State -State $data.state -Message $data.message -Progress $prog -Usage5h $usage -Usage5hResetMs $resetMs -TodoProgress $todoP -Step $step -Total $total -Detail $detail -Family $family } catch { Dbg "POLL ERR: $($_.Exception.Message)" } @@ -717,12 +1214,20 @@ if (Test-Path $statusFile) { $initUsage = $null $initReset = 0 $initTodo = -2 + $initStep = -1 + $initTotal = -1 + $initDetail = '' if ($init.PSObject.Properties['usage5h'] -and $null -ne $init.usage5h) { $initUsage = [int]$init.usage5h } if ($init.PSObject.Properties['usage5hResetMs'] -and $null -ne $init.usage5hResetMs) { $initReset = [int]$init.usage5hResetMs } if ($init.PSObject.Properties['todoProgress'] -and $null -ne $init.todoProgress) { $initTodo = [int]$init.todoProgress } - $script:lastStatusSig = "$($init.state)|$($init.message)|$initProg|$initUsage|$initReset|$initTodo|$($init.ts)" + if ($init.PSObject.Properties['step'] -and $null -ne $init.step) { $initStep = [int]$init.step } + if ($init.PSObject.Properties['total'] -and $null -ne $init.total) { $initTotal = [int]$init.total } + if ($init.PSObject.Properties['detail'] -and $null -ne $init.detail) { $initDetail = [string]$init.detail } + $initFamily = '' + if ($init.PSObject.Properties['family'] -and $init.family) { $initFamily = [string]$init.family } + $script:lastStatusSig = "$($init.state)|$($init.message)|$initFamily|$initProg|$initUsage|$initReset|$initTodo|$initStep|$initTotal|$initDetail|$($init.ts)" $script:lastStatusMtime = (Get-Item $statusFile).LastWriteTimeUtc.Ticks - Update-State -State $init.state -Message $init.message -Progress $initProg -Usage5h $initUsage -Usage5hResetMs $initReset -TodoProgress $initTodo + Update-State -State $init.state -Message $init.message -Progress $initProg -Usage5h $initUsage -Usage5hResetMs $initReset -TodoProgress $initTodo -Step $initStep -Total $initTotal -Detail $initDetail -Family $initFamily } catch {} } else { Update-State -State 'idle' -Message '' diff --git a/plugins/antianqi/mcode-island/mcode-status-detect.ps1 b/plugins/antianqi/mcode-island/mcode-status-detect.ps1 index e0068f0c..d14e1fae 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 (等用户) @@ -27,13 +26,16 @@ # 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, - [string]$Root = '' # override: /.minimax-code and /.minimax/v2/... + [string]$Root = '' # override: the directory that holds .minimax/v2/sessions ) $ErrorActionPreference = 'Stop' @@ -56,82 +58,126 @@ 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_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_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 -$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 -# 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 +$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 +# past-tense verb ("Ran") once it returns, instead of a generic tool name. +$TOOL_ACTIONS = @{ + 'bash' = @{ running='Running'; done='Ran'; fail='Command failed' } + 'shell' = @{ running='Running'; done='Ran'; fail='Command failed' } + 'edit' = @{ running='Editing'; done='Edited'; fail='Edit failed' } + 'write' = @{ running='Writing'; done='Wrote'; fail='Write failed' } + 'read' = @{ running='Reading'; done='Read'; fail='Read failed' } + 'grep' = @{ running='Searching'; done='Searched'; fail='Search failed' } + 'search' = @{ running='Searching'; done='Searched'; fail='Search failed' } + 'glob' = @{ running='Listing'; done='Listed'; fail='List failed' } + 'list' = @{ running='Listing'; done='Listed'; fail='List failed' } + 'task' = @{ running='Delegating'; done='Delegated'; fail='Delegation failed' } + 'web_search' = @{ running='Web searching'; done='Web searched'; fail='Web search failed' } + 'web_fetch' = @{ running='Fetching'; done='Fetched'; fail='Fetch failed' } + 'todowrite' = @{ running='Planning'; done='Planned'; fail='Plan update failed' } + 'task_output' = @{ running='Reading task'; done='Read task'; fail='Task read failed' } + 'task_append' = @{ running='Steering task'; done='Steered task'; fail='Task steer failed' } + 'task_query' = @{ running='Checking tasks'; done='Checked tasks'; fail='Task check failed' } + 'ask_user' = @{ running='Asking'; done='Asked'; fail='Question failed' } + 'request_feature_enable' = @{ running='Requesting access'; done='Access requested'; fail='Access request failed' } +} +# Tool family — the pill tints its dot by family instead of by state, so a +# read looks different from a write at a glance. Mirrors the `family` field +# of the mcode CLI TUI descriptor table (launcher-GHPADSKI.js HL[]). +# Unknown tools get no family (null) and fall back to the state color. +$TOOL_FAMILIES = @{ + 'bash' = 'shell'; 'shell' = 'shell' + 'read' = 'read' + 'edit' = 'write'; 'write' = 'write' + 'grep' = 'search'; 'search' = 'search'; 'glob' = 'search'; 'list' = 'search' + 'task' = 'task'; 'task_output' = 'task'; 'task_append' = 'task' + # ask_user is deliberately absent: it maps to the `waiting` state (see + # Infer-State), and a waiting pill should wear the state color, not a family + # tint. Giving it 'task' would paint the "come back, I'm blocked" signal in + # the same cyan as an in-flight delegation. + 'web_search' = 'web'; 'web_fetch' = 'web' + 'todowrite' = 'plan' + 'request_feature_enable' = 'plan' +} +# default for tools not in the table (kept lowercase so it reads like a name) +$TOOL_FALLBACK_RUNNING = '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 } -$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' # 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" -$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 {} +# 这是一个**可选**能力。真正干活的代码(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 +# 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 +# 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" $script:plan5hLastCallAt = [DateTime]::MinValue $script:plan5hRemainingPct = $null # 0..100,剩余百分比(不再是已用!) $script:plan5hResetMs = $null # 距下次刷新的毫秒数 @@ -141,120 +187,117 @@ $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 } +$script:lastMsgsSnapshot = @{ path = ''; 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 +# 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 - 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')) { + $script:logPending += $line.Length + 2 + if ($script:logPending -ge [int]($script:logMaxBytes / 4)) { + $script:logPending = 0 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 - } + $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 {} + } catch { + # 截断失败不能拖垮检测循环;下一轮阈值到了会再试一次。 + } } - return $bestPid + if (-not $Once) { Write-Output $line } } function Get-LatestSessionFile { @@ -288,43 +331,250 @@ 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 + $script:lastMsgsSnapshot.lastInferred = $msg + return $msg + } catch { + # 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 + } +} + +# Normalize a tool name to the key shape used by $TOOL_ACTIONS +# (mcode's own Wt(): trim, lowercase, '-' -> '_'). Untrusted agent-supplied +# strings, so guard the length: a 4KB "tool name" must not become a map key. +function Get-ToolKey($name) { + if (-not $name) { return '' } + $n = ([string]$name).Trim().ToLowerInvariant().Replace('-', '_') + if ($n.Length -gt 64) { $n = $n.Substring(0, 64) } + return $n +} + +# Resolve the action verb for a tool in a given phase. +# phase: 'running' | 'done' | 'fail' +# Returns '' for an unknown tool so callers can fall back to the raw name. +function Get-ToolVerb($name, $phase) { + $key = Get-ToolKey $name + if (-not $key) { return '' } + $entry = $TOOL_ACTIONS[$key] + if (-not $entry) { return '' } + switch ($phase) { + 'running' { return [string]$entry.running } + 'done' { return [string]$entry.done } + 'fail' { return [string]$entry.fail } + } + return '' +} + +# Verb for an unknown tool: "Using " / "Used ", so the pill +# never falls back to a bare identifier with no signal about the phase. +function Get-ToolVerbFallback($name, $phase) { + $key = Get-ToolKey $name + if (-not $key) { return '' } + if ($phase -eq 'running') { return "$TOOL_FALLBACK_RUNNING $key" } + return "$TOOL_FALLBACK_DONE $key" +} + +function Get-ToolFamily($name) { + $key = Get-ToolKey $name + if (-not $key) { return $null } + $fam = $TOOL_FAMILIES[$key] + if ($fam) { return [string]$fam } + return $null +} + +# Human-readable one-liner for a tool's arguments (round-20 #6). +# +# The running-state message used to be built as +# "$verb " + (ConvertTo-Json $args -Compress) truncated to 60 chars +# which put this on an always-on-top, one-line pill: +# +# Running {"command":"$ErrorActionPreference=\u0027Continue\u0027\n... +# +# Escaped quotes, a JSON key, and a truncation that can land mid-token. +# `skill` is not in $TOOL_ACTIONS or $TOOL_FAMILIES, so it always took +# that path -- one of the most frequent tools on screen was also the +# least readable. +# +# Contract: return the single most informative field for the tool, or '' +# when there is nothing worth showing. Callers render the verb alone in +# that case. Raw JSON is never returned -- there is no fallback to it. +# +# Unknown tools get a generic sweep of the common field names before +# giving up, because the tool taxonomy is hand-maintained and does not +# cover every tool mcode ships. +# NB: the second parameter must NOT be called `$args`. That name is a +# reserved automatic variable in PowerShell, and binding a parameter to +# it yields the unbound-argument Object[] rather than the object passed in +# -- verified: `function T($n, $args) { $args.command }` called with +# `[PSCustomObject]@{command='npm test'}` returns an empty string, because +# the parameter arrives as Object[]. Symptom is a summary that is empty for +# every single tool, which looks like "no data" rather than "bad binding". +function Format-ToolArgs($name, $toolArgs) { + if (-not $toolArgs) { return '' } + $key = ([string]$name).ToLowerInvariant() + $raw = '' + switch ($key) { + 'bash' { $raw = [string]$toolArgs.command } + 'shell' { $raw = [string]$toolArgs.command } + 'read' { $raw = [string]$toolArgs.file_path } + 'write' { $raw = [string]$toolArgs.file_path } + 'edit' { $raw = [string]$toolArgs.file_path } + 'notebookedit' { $raw = [string]$toolArgs.notebook_path } + 'grep' { $raw = [string]$toolArgs.pattern } + 'glob' { $raw = [string]$toolArgs.pattern } + 'list' { $raw = [string]$toolArgs.path } + 'search' { $raw = [string]$toolArgs.query } + 'web_search' { $raw = [string]$toolArgs.query } + 'web_fetch' { $raw = [string]$toolArgs.url } + 'skill' { $raw = [string]$toolArgs.name } + 'task' { $raw = [string]$toolArgs.description } + 'task_output' { $raw = [string]$toolArgs.task_id } + 'task_append' { $raw = [string]$toolArgs.task_id } + 'task_query' { $raw = [string]$toolArgs.task_id } + default { + foreach ($f in 'name','command','file_path','path','pattern','query','url','description','task_id') { + if ($toolArgs.PSObject.Properties[$f]) { + $v = [string]$toolArgs.$f + if (-not [string]::IsNullOrWhiteSpace($v)) { $raw = $v; break } } } - return $null - } finally { - $fs.Close() } - } catch { - return $script:lastMsgsSnapshot.lastInferred } + if ([string]::IsNullOrWhiteSpace($raw)) { return '' } + + # The pill is a single line, and an agent command is usually a short + # script rather than one line. Prefer the first line that actually + # EXECUTES something over the first line that merely sets the stage. + # + # `$ErrorActionPreference='Continue'` is the opening line of most agent + # commands and says nothing about what is being worked on; reading it + # off a status pill is indistinguishable from the pill being stuck. The + # first real command below it -- `npm test`, `Get-ChildItem` -- is the + # line the user actually wants. + $fallback = '' + $first = '' + foreach ($ln in ($raw -split "`r?`n")) { + $t = $ln.Trim() + if ($t -eq '') { continue } + if ($fallback -eq '') { $fallback = $t } + if ($t.StartsWith('#')) { continue } # comment + if ($t.StartsWith('$')) { continue } # variable assignment / preference set + $first = $t + break + } + # Every line was boilerplate. Showing the assignment beats showing nothing. + if ($first -eq '') { $first = $fallback } + if ([string]::IsNullOrWhiteSpace($first)) { return '' } + + # Same redaction the hook path applies, so a credential in a command + # cannot reach the pill or island.log through this route either. + $first = Protect-SecretText $first + $first = ($first -replace '\s+', ' ').Trim() + if ($first.Length -gt 60) { $first = $first.Substring(0, 57) + $DOTS } + return $first +} + +# ask_user is the one tool whose "in flight" state means the agent is BLOCKED, +# not busy. When the questionnaire pops, nothing progresses until the user +# comes back and answers. Reporting it as `working` tells the user "leave it +# alone, it's making progress", which is the opposite of the truth -- so it +# maps to `waiting` (the same state the permission hook uses) with a question +# count, because "Asking 2 questions" is exactly the "come back" signal. +# +# Returns $null for every other tool, so the caller can fall through to the +# normal verb lookup. +function Test-IsAskUser($name) { + return ((Get-ToolKey $name) -eq 'ask_user') +} + +function Get-AskQuestionCount($toolArgs) { + # Parameter is deliberately NOT named $args: that is a PowerShell automatic + # variable, and shadowing it makes every read inside the function return the + # function's own argument list instead of the caller's value. It silently + # produced a count of 0 for every input, which degraded the message to a + # bare "Asking". + if (-not $toolArgs) { return 0 } + try { + $a = $toolArgs + if ($a -is [string]) { $a = $a | ConvertFrom-Json -ErrorAction Stop } + $qs = $a.PSObject.Properties['questions'] + if (-not $qs -or $null -eq $qs.Value) { return 0 } + return @($qs.Value).Count + } catch { return 0 } } function Infer-State($msg) { @@ -337,11 +587,28 @@ function Infer-State($msg) { $kind = [string]$msg.kind $phase = [string]$msg.phase if ($kind -eq $S_TOOLRESULT) { - if ($msg.isError -eq $true) { return @{ state=$S_ERROR; message="$($msg.toolName) $MSG_FAIL" } } - return @{ state=$S_DONE; message="$($msg.toolName) $MSG_OK" } + $tv = [string]$msg.toolName + if ($msg.isError -eq $true) { + $verb = Get-ToolVerb $tv 'fail' + if (-not $verb) { $verb = Get-ToolVerbFallback $tv 'fail' } + return @{ state=$S_ERROR; message=$verb; family=$null } + } + $verb = Get-ToolVerb $tv 'done' + if (-not $verb) { $verb = Get-ToolVerbFallback $tv 'done' } + return @{ state=$S_DONE; message=$verb; family=(Get-ToolFamily $tv) } } if ($kind -eq 'toolCall' -or $kind -eq $S_TOOLCALL) { - return @{ state=$S_WORKING; message="$($msg.toolName) : $($msg.action)" } + $tv = [string]$msg.toolName + if (Test-IsAskUser $tv) { + $n = Get-AskQuestionCount $msg.arguments + if ($n -gt 0) { return @{ state=$S_WAITING; message="Asking $n question$(if ($n -gt 1) { 's' })"; family=$null } } + return @{ state=$S_WAITING; message='Asking'; family=$null } + } + $verb = Get-ToolVerb $tv 'running' + if (-not $verb) { $verb = Get-ToolVerbFallback $tv 'running' } + $detail = [string]$msg.action + if ($detail) { return @{ state=$S_WORKING; message="$verb $detail"; family=(Get-ToolFamily $tv) } } + return @{ state=$S_WORKING; message=$verb; family=(Get-ToolFamily $tv) } } if ($kind -eq $S_ASSISTANT -and $phase -eq $S_THINKING) { return @{ state=$S_THINKING; message='' } @@ -371,12 +638,21 @@ function Infer-State($msg) { if ($hasTool) { $tool = $hasTool.name $args = $hasTool.arguments - if ($args) { - # ConvertTo-Json is a single .NET call; keep it (no pipeline leak). - $argsJson = $args | ConvertTo-Json -Compress -Depth 2 -WarningAction SilentlyContinue - if ($argsJson.Length -gt 60) { $argsJson = $argsJson.Substring(0, 57) + $DOTS } - } else { $argsJson = '' } - return @{ state=$S_WORKING; message="$tool : $argsJson" } + if (Test-IsAskUser $tool) { + $n = Get-AskQuestionCount $args + if ($n -gt 0) { return @{ state=$S_WAITING; message="Asking $n question$(if ($n -gt 1) { 's' })"; family=$null } } + return @{ state=$S_WAITING; message='Asking'; family=$null } + } + $verb = Get-ToolVerb $tool 'running' + if (-not $verb) { $verb = Get-ToolVerbFallback $tool 'running' } + # Readable summary, never raw JSON (round-20 #6). When there is + # nothing worth showing the pill wears the bare verb, which reads + # fine -- "Using" beats "Using {\"name\":\"...\"}". + $summary = Format-ToolArgs $tool $args + if ($summary) { + return @{ state=$S_WORKING; message="$verb $summary"; family=(Get-ToolFamily $tool) } + } + return @{ state=$S_WORKING; message=$verb; family=(Get-ToolFamily $tool) } } if ($hasThink -and -not $hasText) { return @{ state=$S_THINKING; message='' } } if ($hasText) { return @{ state=$S_IDLE; message=$MSG_AGENT_DONE } } @@ -386,8 +662,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,54 +677,134 @@ function Infer-State($msg) { return $null } -function Write-Status($state, $message) { - $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 列表 + # 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 } + + # 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): + # + # - 元数据刷新(-MetadataRefresh):每 60s 刷 5h 用量、每次刷 todo 进度。 + # 这两次的 state/message 是从当前 status.json 读回来原样再写回去的, + # 不代表"agent 进入了新的一步",所以必须把已有的 sub-step 合并回来, + # 否则正常使用中的进度会被静默清零(round-19 #2)。 + # + # - 状态推断(默认):detector 从 session log 推出一个新状态并写入。 + # 这条消息的语义是"agent 现在在干这个",它和上一条 hook 推的 + # sub-step 没有任何承接关系。无条件合并会让 pill 一直挂着上一步的 + # 计数:实测里 agent 早已换工具、甚至这一轮都结束了,pill 还卡在 + # "step 1/1" 显示一条早就跑完的 curl 命令,而且没有任何后续写入会 + # 把它清掉。所以状态推断一律重置。 + $stepField = -1 + $totalField = -1 + $detailField = '' + # `source` is the arbitration token, not a label (round-20 #7). The + # detector's settle logic reads `source == detector` as "this state is + # mine to rewrite". A metadata refresh restates the current state and + # refreshed numbers -- it does not claim the state -- so stamping + # `detector` on it silently hands write authority back to the detector. + # + # Observed consequence: a tool fails, the detector infers `error`, the + # PostToolUse hook pushes the same `error` (source=agent), then the 60s + # 5h-usage refresh stamps `detector` over it. From then on the detector + # owns the state and re-derives `error` from the same stale failed + # toolResult on every poll, clobbering the Stop hook's `done`. The pill + # sat on "Command failed" until the 60s no-event fallback finally + # dropped it to idle. + $sourceField = $S_DETECTOR + if ($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 usage5hResetMs = $resetField todoProgress = $todoPct todosCount = $todoCnt - ts = (Get-Date).ToString($FMT_O) - source = $S_DETECTOR - } | ConvertTo-Json -Compress - [System.IO.File]::WriteAllText($tmp, $payload, [System.Text.Encoding]::UTF8) - Move-Item -Path $tmp -Destination $statusFile -Force + step = $stepField + total = $totalField + detail = $detailField + source = $sourceField + } + # `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 了它). -# 抽到 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 { @@ -492,10 +854,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) { @@ -505,41 +868,48 @@ 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 - # 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 } } # 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) { @@ -549,54 +919,81 @@ 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) - $isSettleNew = ($newState -eq $S_IDLE) -or ($newState -eq $S_ERROR) - - $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 ($shouldWrite) { - Write-Status $inferred.state $inferred.message - if ($script:lastDetectedState -ne $newState) { - Log-Line "$newState :: $newMsg ($reason)" - $script:lastDetectedState = $newState + 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-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 看到) $prevPct = $script:plan5hRemainingPct $prevMs = $script:plan5hResetMs - 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) if ($usageChanged) { - $curForUsage = Read-StatusObj - $sForU = if ($curForUsage) { [string]$curForUsage.state } else { $S_IDLE } - $mForU = if ($curForUsage) { [string]$curForUsage.message } else { '' } - Write-Status $sForU $mForU + # 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) } @@ -606,10 +1003,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 { '' } - Write-Status $sForT $mForT + Write-Status -MetadataRefresh $script:plan5hLastWrittenTodoPct = $curTodoPct if ($null -ne $curTodoData) { Log-Line ("todo refreshed: " + $curTodoData.completed + "/" + $curTodoData.total + " = " + $curTodoPct + "%") @@ -620,11 +1014,21 @@ 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)) { 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/notify-island.ps1 b/plugins/antianqi/mcode-island/notify-island.ps1 index f20451e7..bb8ec036 100644 --- a/plugins/antianqi/mcode-island/notify-island.ps1 +++ b/plugins/antianqi/mcode-island/notify-island.ps1 @@ -10,16 +10,32 @@ 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' [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) @@ -109,13 +125,16 @@ $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 progress = $Progress - ts = $ts + step = $Step + total = $Total + detail = $Detail source = 'agent' -} | ConvertTo-Json -Compress +} # 写 caller.json(widget 点击切回用) $myPid = $PID @@ -132,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/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/plugin.json b/plugins/antianqi/mcode-island/plugin.json index f39491dc..cc3c26f8 100644 --- a/plugins/antianqi/mcode-island/plugin.json +++ b/plugins/antianqi/mcode-island/plugin.json @@ -1,8 +1,8 @@ { "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "mcode-island", - "version": "0.3.0", - "description": "Windows 桌面灵动岛 (Dynamic Island) 状态窗口:让 mcode agent 把工作状态(idle/thinking/working/waiting/done/error)实时推送到屏幕顶部悬浮 pill,agent 自己忙的时候用户不用切回 mcode 也能看到进度。v0.3.0 增加 io.minimax.mcode 客户端扩展(Hooks 草案),与 MiniMax-Code-Plugins PR #20 的 portable Hooks 提案对齐;mcode 0.2.4+ Runtime 触发,registry 接受后零改动生效。", + "version": "0.4.0", + "description": "Windows 桌面灵动岛 (Dynamic Island) 状态窗口:让 mcode agent 把工作状态(idle/thinking/working/waiting/done/error)实时推送到屏幕顶部悬浮 pill,agent 自己忙的时候用户不用切回 mcode 也能看到进度。v0.4.0 扩展 status.json schema 增加 step/total/detail 三个字段并更新 widget 渲染:agent 现在可以推送 'step 3/12 · fill username field' 这类 sub-step 进度,Computer Use 多步循环、长任务分阶段展示。Backward compat:所有新字段 optional。v0.3.0 增加 io.minimax.mcode 客户端扩展(Hooks 草案),与 MiniMax-Code-Plugins PR #20 的 portable Hooks 提案对齐;mcode 0.2.4+ Runtime 触发,registry 接受后零改动生效。", "author": { "name": "antianqi", "url": "https://github.com/antianqi" diff --git a/plugins/antianqi/mcode-island/scripts/lib/Get-5hUsage.ps1 b/plugins/antianqi/mcode-island/scripts/lib/Get-5hUsage.ps1 index cc312823..d010c121 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: # @@ -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.minimax.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 } @@ -109,3 +116,82 @@ function Get-5hUsage { return $null } } + +# 5h usage API 节流窗口 +$PLAN_API_TTL = [TimeSpan]::FromSeconds(60) + +# Resolve-PlanToken +# +# 解析 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,一次 +# 网络请求都不发。 +# +# 放在 lib 里而不是 detector 里,是为了让"要不要用 5h 用量"这件事 +# 完全由 lib 的存在与否决定:lib 不在(Marketplace 上架包就不带它), +# detector 既不读 token 也不联网。 +. "$PSScriptRoot/Protect-Token.ps1" + +function Resolve-PlanToken { + $script:plan5hToken = $null + + $cfgPath = Get-Variable -Name 'cfgFile' -ValueOnly -ErrorAction SilentlyContinue + if (-not $cfgPath) { $cfgPath = Join-Path $env:APPDATA 'mcode-island\config.json' } + + $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 {} + } + } +} + +# 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 + } +} 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..0cf32bec --- /dev/null +++ b/plugins/antianqi/mcode-island/scripts/lib/Plan-Usage.ps1 @@ -0,0 +1,113 @@ +# 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) 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 + (Join-Path $env:APPDATA 'mcode-island\usage.json') + (Join-Path $env:USERPROFILE '.minimax\usage.json') +) + +function Get-PlanUsage { + # 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) { + 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 +} 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/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/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 e478922d..2b3eb669 100644 --- a/plugins/antianqi/mcode-island/scripts/smoke.mjs +++ b/plugins/antianqi/mcode-island/scripts/smoke.mjs @@ -23,7 +23,7 @@ // 6. cross-platform: no hardcoded host-absolute paths, no // /Users/ or /home/ literals in any script or hooks.json entry -import { readFile, stat } from 'node:fs/promises'; +import { readFile, stat, readdir } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import { dirname, join, resolve, sep } from 'node:path'; @@ -155,7 +155,7 @@ const checkEntry = async (event, entry) => { }; const main = async () => { - console.log(`mcode-island v0.3.0 self-check`); + console.log(`mcode-island v0.4.0 self-check`); console.log(`plugin root: ${PLUGIN_ROOT}`); console.log('-'.repeat(60)); @@ -182,8 +182,8 @@ const main = async () => { } else { out('PASS', `plugin.json: name is "${plugin.name}"`); } - if (plugin.version !== '0.3.0') { - out('FAIL', `plugin.json: version is "${plugin.version}", expected "0.3.0"`); + if (plugin.version !== '0.4.0') { + out('FAIL', `plugin.json: version is "${plugin.version}", expected "0.4.0"`); } else { out('PASS', `plugin.json: version is "${plugin.version}"`); } @@ -338,6 +338,693 @@ const main = async () => { } } + // 5c1. Sub-step progress extension (round-13 refactor). + // notify-island.ps1 must accept -Step/-Total/-Detail and write them + // into status.json. mcode-island.ps1 widget must define + // Build-DisplayMessage and pass step/total/detail to it. + // _lib.ps1 Format-ToolSummary must extract mcode-computer-use + // action+coordinate; Push-Island must forward the new fields. + // The detailed functional tests live in test-substep-progress.mjs; + // here we lock the surface contract so a future refactor that + // drops the params surfaces in smoke (fast path) before reaching + // the slower pwsh-spawned tests. + const substepNotifyPath = join(PLUGIN_ROOT, 'notify-island.ps1'); + const substepWidgetPath = join(PLUGIN_ROOT, 'mcode-island.ps1'); + if (!(await exists(substepNotifyPath))) { + out('FAIL', 'notify-island.ps1 missing (sub-step lock skipped)'); + } else { + const notify = await readFile(substepNotifyPath, 'utf8'); + if (!/\[int\]\$Step\s*=\s*-1/.test(notify) || + !/\[int\]\$Total\s*=\s*-1/.test(notify) || + !/\[string\]\$Detail\s*=\s*''/.test(notify)) { + out('FAIL', 'notify-island.ps1: missing -Step/-Total/-Detail params'); + } else { + out('PASS', 'notify-island.ps1: declares -Step -Total -Detail'); + } + if (!/step\s*=\s*\$Step/.test(notify) || + !/total\s*=\s*\$Total/.test(notify) || + !/detail\s*=\s*\$Detail/.test(notify)) { + out('FAIL', 'notify-island.ps1: status.json payload missing step/total/detail fields'); + } else { + out('PASS', 'notify-island.ps1: writes step/total/detail to status.json'); + } + } + if (await exists(substepWidgetPath)) { + const widget = await readFile(substepWidgetPath, 'utf8'); + if (!/function Build-DisplayMessage/.test(widget)) { + out('FAIL', 'mcode-island.ps1: Build-DisplayMessage function missing'); + } else { + out('PASS', 'mcode-island.ps1: Build-DisplayMessage function present'); + } + if (!/\[int\]\$Step\s*=\s*-1/.test(widget) || + !/\[int\]\$Total\s*=\s*-1/.test(widget)) { + out('FAIL', 'mcode-island.ps1: Update-State missing Step/Total params'); + } else { + out('PASS', 'mcode-island.ps1: Update-State accepts Step/Total/Detail'); + } + } + const substepTestPath = join(PLUGIN_ROOT, 'scripts', 'test-substep-progress.mjs'); + if (!(await exists(substepTestPath))) { + out('WARN', 'scripts/test-substep-progress.mjs missing (sub-step detailed tests not run)'); + } else { + out('PASS', 'scripts/test-substep-progress.mjs exists (run separately for full suite)'); + } + + // 5c2. Click 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'); + + // (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)`); + } + } + + // (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 { + 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'"); + } + } + + // (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 (!/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 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'); + } + } + + // (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 + // 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-Window[\s\S]*?\n\}\n/); + if (!toggleMatch) { + out('WARN', 'mcode-island.ps1: Toggle-Window body not found (drift lock skipped)'); + } else { + const toggleBody = toggleMatch[0]; + // Extract the `else` branch (the restore path) so the check + // is anchored on the show branch, not the hide branch (which + // intentionally uses SW_HIDE=0). + const elseMatch = toggleBody.match(/else\s*\{([\s\S]*?)\n\s*\}\s*\n\s*\}\s*$/m); + const restoreBody = elseMatch ? elseMatch[1] : ''; + if (!restoreBody) { + out('FAIL', 'mcode-island.ps1: Toggle-Window else branch not parseable'); + } else if (!/ShowWindow\(\s*\$Target\.Hwnd\s*,\s*3\s*\)/.test(restoreBody)) { + out('FAIL', 'mcode-island.ps1: Toggle restore branch does not call SW_MAXIMIZE (ShowWindow(_, 3)). A regression to SW_SHOW (5) or SW_RESTORE (9) re-shows the window at its pre-hide size (e.g., 480x84 strip if WT got accidentally resized).'); + } else if (/ShowWindow\(\s*\$Target\.Hwnd\s*,\s*5\s*\)/.test(restoreBody)) { + out('FAIL', 'mcode-island.ps1: Toggle restore branch calls SW_SHOW (5) in addition to SW_MAXIMIZE — keep only SW_MAXIMIZE; SW_SHOW re-shows at pre-hide size and defeats the maximize intent.'); + } 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\(\$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)'); + } + + // Round-17 + round-19: the follow-up z-order SetWindowPos call + // (HWND_TOP, to push WT forward without foreground permission) + // must carry both SWP_NOSIZE and SWP_NOMOVE, and must NOT carry + // SWP_NOZORDER. + // + // SWP_NOSIZE cx=0/cy=0 is otherwise "resize to 0x0", triggering + // WT's min-size fallback to a 480x76 strip — the + // exact regression the user saw in round-17. + // SWP_NOMOVE X=0/Y=0 is otherwise "move to (0,0)". Invisible on + // a single primary monitor, but any secondary monitor + // whose origin is not 0 gets the restored window + // yanked to the primary's top-left corner + // (round-19 review #3). + // no SWP_NOZORDER that flag makes Windows ignore + // hWndInsertAfter entirely, so passing HWND_TOP + // alongside it is self-defeating: the whole point of + // this call is the z-order change. + // + // The round-17 version of this lock asserted the literal + // `SWP_NOZORDER -bor SWP_NOSIZE`, which is exactly the pair the + // review asked to change, so it had to be rewritten rather than + // updated. Match on the flag names, not their order. + if (!/SWP_NOSIZE\s*=\s*0x0001/.test(widget)) { + out('FAIL', 'mcode-island.ps1: WinAPI class missing SWP_NOSIZE constant (0x0001).'); + } else if (!/SWP_NOMOVE\s*=\s*0x0002/.test(widget)) { + out('FAIL', 'mcode-island.ps1: WinAPI class missing SWP_NOMOVE constant (0x0002).'); + } else { + // Pull the flags expression that feeds the HWND_TOP call. It + // is assigned just ABOVE the call, not inside it, so anchor on + // the assignment and look for the HWND_TOP call within the + // same statement rather than scanning forward from the call. + const flagsExpr = (toggleBody.match(/\$nofollow\s*=\s*([^\n\r]+)/) || [null, ''])[1]; + const hasZorderCall = /SetWindowPos\([^)]*HWND_TOP/.test(toggleBody); + + const problems = []; + if (!hasZorderCall) { + problems.push('the HWND_TOP SetWindowPos call is gone, so the window is never pushed forward'); + } + if (!/SWP_NOSIZE/.test(flagsExpr)) { + problems.push('SWP_NOSIZE (cx=0/cy=0 would resize WT to 0x0 and trigger its 480x76 min-size fallback)'); + } + if (!/SWP_NOMOVE/.test(flagsExpr)) { + problems.push('SWP_NOMOVE (X=0/Y=0 would move the window to (0,0) on any monitor whose origin is not 0)'); + } + if (/SWP_NOZORDER/.test(flagsExpr)) { + problems.push('SWP_NOZORDER is present, which makes Windows ignore hWndInsertAfter and defeats the HWND_TOP z-order call'); + } + + if (problems.length > 0) { + for (const p of problems) { + out('FAIL', `mcode-island.ps1: Toggle z-order SetWindowPos(HWND_TOP) — ${p}`); + } + } else { + out('PASS', 'mcode-island.ps1: Toggle z-order SetWindowPos carries SWP_NOSIZE + SWP_NOMOVE and omits SWP_NOZORDER (size preserved, position preserved, z-order actually applied)'); + } + } + } + } + + // 5d. Drift lock: the round-18 tool-verb table. The pill shows a + // present-tense verb ("Running") while a tool is in flight and a + // past-tense one ("Ran") once it returns, mirroring the mcode CLI TUI + // descriptor table (launcher-GHPADSKI.js HL[]). Before this the pill + // showed the bare tool name for every phase, so "working" and "done" + // were indistinguishable at a glance. + // + // These locks are deliberately text-shaped rather than behavioral: a + // behavioral test would need a real mcode session log. What we can + // cheaply guarantee is that (a) the table exists, (b) it covers the + // tools the detector actually infers, and (c) all three Infer-State + // branches route through the verb lookup instead of hardcoding names. + const detectPath = join(PLUGIN_ROOT, 'mcode-status-detect.ps1'); + if (!(await exists(detectPath))) { + out('FAIL', 'mcode-status-detect.ps1 missing (tool-verb drift lock skipped)'); + } else { + const detect = await readFile(detectPath, 'utf8'); + + // (a) table + helpers present + for (const [label, re] of [ + ['$TOOL_ACTIONS table', /\$TOOL_ACTIONS\s*=\s*@\{/], + ['$TOOL_FAMILIES table', /\$TOOL_FAMILIES\s*=\s*@\{/], + ['Get-ToolKey helper', /function Get-ToolKey\s*\(/], + ['Get-ToolVerb helper', /function Get-ToolVerb\s*\(/], + ['Get-ToolVerbFallback helper', /function Get-ToolVerbFallback\s*\(/], + ['Get-ToolFamily helper', /function Get-ToolFamily\s*\(/], + ]) { + if (re.test(detect)) { + out('PASS', `mcode-status-detect.ps1: ${label} present`); + } else { + out('FAIL', `mcode-status-detect.ps1: ${label} missing (round-18 tool-verb contract broken)`); + } + } + + // (b) the table covers the tools users actually see. Each entry must + // carry all three phases; a partial entry would render an empty + // message and the pill would silently go blank for that tool. + const actionsMatch = detect.match(/\$TOOL_ACTIONS\s*=\s*@\{([\s\S]*?)\n\}/); + if (!actionsMatch) { + out('FAIL', 'mcode-status-detect.ps1: cannot slice $TOOL_ACTIONS body'); + } else { + const body = actionsMatch[1]; + const entryRe = /'([a-z0-9_]+)'\s*=\s*@\{\s*running\s*=\s*'([^']*)'\s*;\s*done\s*=\s*'([^']*)'\s*;\s*fail\s*=\s*'([^']*)'\s*\}/g; + const found = new Map(); + let m; + while ((m = entryRe.exec(body)) !== null) { + found.set(m[1], { running: m[2], done: m[3], fail: m[4] }); + } + out('PASS', `mcode-status-detect.ps1: $TOOL_ACTIONS has ${found.size} entries with all 3 phases`); + + for (const required of ['bash', 'read', 'write', 'edit', 'grep', 'glob', 'task']) { + if (found.has(required)) { + out('PASS', `$TOOL_ACTIONS covers "${required}"`); + } else { + out('FAIL', `$TOOL_ACTIONS is missing "${required}" (the pill would show a bare name for it)`); + } + } + // Every mapped family must resolve to a family, otherwise the + // widget's $familyMap lookup silently falls back to the state + // color and the round-18 tinting never happens. + const famMatch = detect.match(/\$TOOL_FAMILIES\s*=\s*@\{([\s\S]*?)\n\}/); + if (!famMatch) { + out('FAIL', 'mcode-status-detect.ps1: cannot slice $TOOL_FAMILIES body'); + } else { + for (const [key, fam] of Object.entries({ + bash: 'shell', read: 'read', edit: 'write', write: 'write', + grep: 'search', glob: 'search', task: 'task', + task_output: 'task', ask_user: 'task', + web_search: 'web', web_fetch: 'web', todowrite: 'plan', + })) { + // ask_user is intentionally excluded from this list. It was + // mapped to the 'task' family in round-18 and is deliberately + // unmapped in 5d2, where it reports `waiting` instead. + if (key === 'ask_user') continue; + const re = new RegExp(`'${key}'\\s*=\\s*'${fam}'`); + if (re.test(famMatch[1])) { + out('PASS', `$TOOL_FAMILIES maps "${key}" -> "${fam}"`); + } else { + out('FAIL', `$TOOL_FAMILIES does not map "${key}" -> "${fam}" (widget tinting will not fire for it)`); + } + } + } + } + + // (c) all three Infer-State branches must route through the verb + // lookup. This is the actual regression: reverting any one branch to + // `"$($m.toolName) $MSG_OK"` would still pass a "table exists" check + // while the pill went back to showing a bare name. + // + // There are TWO sites per state (the ledger.jsonl branch and the + // messages.jsonl branch), so a presence test is not enough -- breaking + // only the ledger branch leaves the messages branch matching and the + // check stays green. These locks assert a minimum occurrence count so + // that reverting EITHER site goes red. + for (const [label, re, min] of [ + ['WORKING branch uses Get-ToolVerb', /state=\$S_WORKING;\s*message="\$verb\s/g, 2], + ['DONE branch uses Get-ToolVerb', /state=\$S_DONE;\s*message=\$verb/g, 2], + ['ERROR branch uses Get-ToolVerb', /state=\$S_ERROR;\s*message=\$verb/g, 2], + ]) { + const hits = detect.match(re); + const n = hits ? hits.length : 0; + if (n >= min) { + out('PASS', `mcode-status-detect.ps1: ${label} (${n}/${min} sites)`); + } else { + out('FAIL', `mcode-status-detect.ps1: ${label} -- only ${n}/${min} sites use the verb lookup; a branch was reverted to a hardcoded tool name`); + } + } + + // (d) family must be threaded to disk, or the widget can never tint. + if (/family\s*=\s*\$famField/.test(detect)) { + out('PASS', 'mcode-status-detect.ps1: Write-Status emits the family field'); + } else { + out('FAIL', 'mcode-status-detect.ps1: Write-Status does not emit `family` (widget tinting is dead code)'); + } + + // 5d2. Drift lock: ask_user must report `waiting`, not `working`. + // + // ask_user is the only tool whose "in flight" state means the agent is + // BLOCKED on the user rather than busy. Showing it as `working` tells + // the user "leave it alone, it is making progress", which is the + // opposite of the truth and defeats the entire point of the state. + // This matters most on `full access` setups, where the permission hook + // never fires and ask_user is the only thing that ever blocks. + for (const [label, re] of [ + ['$S_WAITING is defined', /\$S_WAITING\s*=\s*(?:_s\s*\(|')/], + ['Test-IsAskUser helper', /function Test-IsAskUser\s*\(/], + ['Get-AskQuestionCount helper', /function Get-AskQuestionCount\s*\(/], + ]) { + if (re.test(detect)) { + out('PASS', `mcode-status-detect.ps1: ${label} present`); + } else { + out('FAIL', `mcode-status-detect.ps1: ${label} missing (ask_user cannot report waiting)`); + } + } + + // Both tool paths (ledger.jsonl and messages.jsonl) must route + // ask_user to waiting. A presence test is not enough here for the same + // reason as the verb locks above: there are two sites, and breaking one + // leaves the other matching. + const askSites = detect.match(/state=\$S_WAITING;\s*message="Asking \$n question/g) || []; + if (askSites.length >= 2) { + out('PASS', `mcode-status-detect.ps1: both ask_user tool paths report waiting (${askSites.length}/2 sites)`); + } else { + out('FAIL', `mcode-status-detect.ps1: only ${askSites.length}/2 ask_user paths report waiting; a path still treats a blocked agent as busy`); + } + + // $args is a PowerShell automatic variable. Naming a function parameter + // $args shadows it, and every read inside the function returns the + // function's own argument list instead of the caller's value -- which + // here silently produced a question count of 0 for every input. Lock + // the parameter name so the regression cannot come back unnoticed. + if (/function Get-AskQuestionCount\(\$toolArgs\)/.test(detect)) { + out('PASS', 'mcode-status-detect.ps1: Get-AskQuestionCount avoids the $args automatic variable'); + } else { + out('FAIL', 'mcode-status-detect.ps1: Get-AskQuestionCount takes $args, which shadows the PowerShell automatic variable and always yields a count of 0'); + } + + // waiting must be in BOTH settle sets. The 60s no-activity fallback + // would otherwise downgrade a pending questionnaire to "已静默 60s" + // exactly when the user has been away longest, and the takeover + // arbitration would let a hook-pushed `working` win forever. + for (const [label, re] of [ + ['60s idle fallback exempts waiting', /\$isSettled\s*=.*-or\s*\(\$curState\s+-eq\s+\$S_WAITING\)/], + ['takeover arbitration treats waiting as settle', /\$isSettleNew\s*=.*-or\s*\(\$newState\s+-eq\s+\$S_WAITING\)/], + ]) { + if (re.test(detect)) { + out('PASS', `mcode-status-detect.ps1: ${label}`); + } else { + out('FAIL', `mcode-status-detect.ps1: ${label} (a pending ask_user would be downgraded or shadowed)`); + } + } + + // ask_user must NOT carry a family tint: it wears the waiting state + // color, and painting it the task cyan would look like an in-flight + // delegation -- the confusion this change exists to remove. + if (!/'ask_user'\s*=\s*'task'/.test(detect)) { + out('PASS', 'mcode-status-detect.ps1: ask_user carries no family tint (waits in the state color)'); + } else { + out('FAIL', "mcode-status-detect.ps1: ask_user is still mapped to the 'task' family, so a blocked agent wears the delegation color"); + } + + // (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 + // active states. Tinting `done`/`error` by family would destroy the + // green=success / red=failure signal the user relies on. + const widgetPath = join(PLUGIN_ROOT, 'mcode-island.ps1'); + if (!(await exists(widgetPath))) { + out('FAIL', 'mcode-island.ps1 missing (family-tint drift lock skipped)'); + } else { + const widget = await readFile(widgetPath, 'utf8'); + // Hoisted out of the if/else below: the hue-separation check further + // down needs the same slice, and a block-scoped const would be out of + // scope by then. + const famBlock = widget.match(/\$familyMap\s*=\s*@\{([\s\S]*?)\n\}/); + if (!famBlock) { + out('FAIL', 'mcode-island.ps1: $familyMap table missing'); + } else { + for (const fam of ['shell', 'read', 'write', 'search', 'task', 'web', 'plan']) { + if (new RegExp(`^\\s*${fam}\\s*=`, 'm').test(famBlock[1])) { + out('PASS', `$familyMap covers "${fam}"`); + } else { + out('FAIL', `$familyMap does not cover "${fam}"`); + } + } + } + if (/\[string\]\$Family\s*=\s*''/.test(widget)) { + out('PASS', 'mcode-island.ps1: Update-State takes a $Family parameter'); + } else { + out('FAIL', 'mcode-island.ps1: Update-State has no $Family parameter'); + } + // The gate must be checked on the *family tint* line specifically. + // `$State -in @('thinking','working','waiting')` also appears on the + // pulse / elapsed / progress branches, so a bare substring test stays + // green after the tint is ungated -- a false green this section exists + // to prevent. Anchor on the `$Family -and $State` conjunction instead. + if (/\$Family\s+-and\s+\$State\s+-in\s+@\('thinking','working','waiting'\)/.test(widget)) { + out('PASS', 'mcode-island.ps1: family tint is gated to active states (done/error keep their result color)'); + } else { + out('FAIL', 'mcode-island.ps1: family tint is not gated to active states (done/error would lose their green/red result signal)'); + } + if (/\$script:statusDot\.Fill\s*=\s*C\s+\$dotHex/.test(widget) + && /\$script:pulseRing\.Fill\s*=\s*C\s+\$ringHex/.test(widget)) { + out('PASS', 'mcode-island.ps1: dot/ring are painted from the resolved color (family-aware)'); + } else { + out('FAIL', 'mcode-island.ps1: dot/ring still read $s.dot directly, bypassing family tinting'); + } + + // Perceptual separation. Two families landing on near-identical colors + // are indistinguishable on the pill, which defeats the point of + // tinting. Measured with CIE76 dE in CIELAB, NOT raw luminance: + // luminance alone calls blue and purple "identical" (0.02 apart) even + // though they are plainly different hues, so a luminance threshold + // just produces false alarms. dE < 20 is the usual "not the same color + // to a human eye" cutoff for flat UI fills. + const famColors = new Map(); + // No `^` anchor: with the `m` flag a leading `\s*` is free to swallow + // the preceding newline and match mid-line, and with a greedy + // [\s\S]* body it can also skip the first entry. A `g`-only scan with + // a `[ \t]*` (not `\s*`) indent keeps one match per table row. + // Colors in the table are 8-digit #AARRGGBB (the alpha byte is FF), + // so the pattern has to be {8} or the closing quote never lines up. + const colorRe = /[ \t]*([a-z]+)[ \t]*=[ \t]*'(#[0-9A-Fa-f]{8})'/g; + let cm; + while ((cm = colorRe.exec(famBlock[1])) !== null) { + famColors.set(cm[1], cm[2].slice(3).toUpperCase()); // drop #FF alpha + } + if (famColors.size < 7) { + out('FAIL', `$familyMap: parsed only ${famColors.size}/7 family colors; the separation check below would be vacuous`); + } else { + out('PASS', `$familyMap: parsed all ${famColors.size} family colors`); + } + // sRGB -> XYZ (D65) -> CIELAB + const toLab = (hex) => { + const ch = [0, 2, 4].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255); + const lin = ch.map((v) => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4)); + const [r, g, b] = lin; + const X = (0.4124564 * r + 0.3575761 * g + 0.1804375 * b) / 0.95047; + const Y = (0.2126729 * r + 0.7151522 * g + 0.0721750 * b); + const Z = (0.0193339 * r + 0.1191920 * g + 0.9503041 * b) / 1.08883; + const f = (t) => (t > 0.008856 ? Math.cbrt(t) : 7.787 * t + 16 / 116); + const [fx, fy, fz] = [f(X), f(Y), f(Z)]; + return [116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)]; + }; + const deltaE = (a, b) => { + const [la, aa, ba] = toLab(a); + const [lb, ab, bb] = toLab(b); + return Math.hypot(la - lb, aa - ab, ba - bb); + }; + const MIN_DE = 20; + const keys = [...famColors.keys()]; + let tooClose = 0; + for (let i = 0; i < keys.length; i++) { + for (let j = i + 1; j < keys.length; j++) { + const d = deltaE(famColors.get(keys[i]), famColors.get(keys[j])); + if (d < MIN_DE) { + out('FAIL', `$familyMap colors "${keys[i]}" (#${famColors.get(keys[i])}) and "${keys[j]}" (#${famColors.get(keys[j])}) are only dE ${d.toFixed(1)} apart (< ${MIN_DE}); they read as the same color on the pill`); + tooClose++; + } + } + } + if (tooClose === 0) { + out('PASS', `$familyMap: all ${keys.length} family colors are perceptually distinct (min pairwise CIELAB dE >= ${MIN_DE})`); + } + } + // 5b. Drift lock: permission-request.ps1 must emit `{"decision":"ask"}`, // not `allow` or `deny`. The 0.2.4 Runtime default for PermissionRequest // is fail-closed; an observer Hook that returns `allow` or `deny` @@ -442,6 +1129,469 @@ 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 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 + // 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 constant (disclosure check has no truth to compare against)'); + } else { + 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: $PLAN_API_HOST is not a parseable URL ("${realUrl}")`); + } else { + out('PASS', `Get-5hUsage.ps1: $PLAN_API_HOST names 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)'); + } + + // 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\ + // must cap their own size. mcode-island.ps1 line 7 has documented + // "keep only the last 1KB" since the widget was written, but Dbg was + // a bare Add-Content and never implemented it -- widget.log reached + // 37 MB in 38 days (~3.6 MB/day of POLL lines during active use), and + // nothing ever trimmed it. A stated cap that no code enforces is a + // comment, not a guarantee, so this check asserts the enforcement + // rather than trusting the prose. + for (const t of [ + { rel: 'mcode-island.ps1', fn: 'Dbg' }, + { rel: 'mcode-status-detect.ps1', fn: 'Log-Line' }, + ]) { + const abs = join(PLUGIN_ROOT, t.rel); + if (!(await exists(abs))) { + out('FAIL', `${t.rel} missing (log-cap check skipped)`); + continue; + } + const text = await readFile(abs, 'utf8'); + const body = text.match(new RegExp(`function\\s+${t.fn}\\s*\\([^)]*\\)\\s*\\{([\\s\\S]*?)\\n\\}`)); + if (!body) { + out('FAIL', `${t.rel}: function ${t.fn} not found (cannot verify the log cap)`); + continue; + } + const src = body[1]; + const capped = /Length/i.test(src) && + /(gt|ge|gt\s)/i.test(src) && + /(SetLength|WriteAllText|Truncate|Get-Content|Rotate|-Tail)/i.test(src); + if (!capped) { + out('FAIL', `${t.rel}: ${t.fn} appends with no size cap (log grows without bound)`); + } else { + out('PASS', `${t.rel}: ${t.fn} bounds its own log size`); + } + } + + // 9. Secret-shaped command text is redacted before it is persisted + // (round-20). + // + // Contract: tool-input text (Bash commands, Computer Use keystrokes) + // is written to status.json, to the append-only logs, and rendered on + // an always-on-top pill. The Computer Use branch of Format-ToolSummary + // was redacted in round-19 #1; the Bash branch was not, so a command + // carrying a bearer token or an API key landed on disk verbatim. + // + // There are two independent producers of that text -- the hook path + // (_lib.ps1) and the detector, which re-derives messages from mcode's + // own session log -- so a helper wired into only one of them is a + // silent half-fix. The helper therefore lives in a shared lib, and + // this check asserts BOTH consumers route through it. Asserting the + // helper merely *exists* is the false-green shape: dead code passes it. + const protectLibAbs = join(PLUGIN_ROOT, 'scripts', 'lib', 'Protect-Text.ps1'); + if (!(await exists(protectLibAbs))) { + out('FAIL', 'scripts/lib/Protect-Text.ps1 missing (command secrets are persisted verbatim)'); + } else { + const protectLib = await readFile(protectLibAbs, 'utf8'); + const helper = protectLib.match(/function\s+(Protect-SecretText|Redact-SecretText)/); + if (!helper) { + out('FAIL', 'scripts/lib/Protect-Text.ps1: Protect-SecretText not defined'); + } else { + out('PASS', `scripts/lib/Protect-Text.ps1: ${helper[1]} defined`); + + for (const consumer of [ + { rel: join('io.minimax.mcode', 'hooks', 'scripts', '_lib.ps1'), label: 'hook path' }, + { rel: 'mcode-status-detect.ps1', label: 'detector' }, + ]) { + const abs = join(PLUGIN_ROOT, consumer.rel); + if (!(await exists(abs))) { + out('FAIL', `${consumer.rel} missing (${consumer.label} redaction not verified)`); + continue; + } + const text = await readFile(abs, 'utf8'); + if (!/Protect-Text\.ps1/.test(text)) { + out('FAIL', `${consumer.rel}: does not dot-source scripts/lib/Protect-Text.ps1`); + continue; + } + // The helper is DEFINED in the other file, so every + // occurrence of its name in this consumer is an actual + // invocation -- the dot-source line names the .ps1 file, + // not the function. A consumer that dot-sources but never + // calls therefore scores 0 and fails here, which is the + // whole point of the check. + const uses = (text.match(new RegExp(helper[1], 'g')) || []).length; + if (uses < 1) { + out('FAIL', `${consumer.rel}: dot-sources Protect-Text.ps1 but never calls ${helper[1]} (${consumer.label} is unredacted)`); + } else { + out('PASS', `${consumer.rel}: ${consumer.label} calls ${helper[1]} (${uses} site(s))`); + } + } + } + } + + // 10. Hook invocations survive an install path containing spaces + // (round-20). + // + // Contract: a plugin can legitimately be installed under a path with + // spaces or shell metacharacters, and mcode 0.5.4 explicitly fixed + // its own managed updater for exactly that case. ${PLUGIN_ROOT} is + // substituted at runtime, so the only way to break a spaced path is + // to hand the shell a pre-joined string instead of an argv array. + // This asserts the array form survives -- a "command" that embeds the + // path in a quoted shell string, or an args entry that is itself a + // joined string with embedded quotes, is exactly the regression. + const hooksJsonAbs = join(PLUGIN_ROOT, 'io.minimax.mcode', 'hooks', 'hooks.json'); + if (!(await exists(hooksJsonAbs))) { + out('FAIL', 'io.minimax.mcode/hooks/hooks.json missing (space-path check skipped)'); + } else { + let parsed = null; + try { + parsed = JSON.parse(await readFile(hooksJsonAbs, 'utf8')); + } catch (e) { + out('FAIL', `io.minimax.mcode/hooks/hooks.json: not parseable as JSON (${e.message})`); + } + if (parsed) { + let entries = 0, bad = 0; + for (const [event, list] of Object.entries(parsed.hooks || {})) { + for (const h of (Array.isArray(list) ? list : [list])) { + if (!h || typeof h !== 'object') continue; + entries++; + const cmd = typeof h.command === 'string' ? h.command : ''; + // A command that inlines the plugin root has been + // flattened into a shell string; ${PLUGIN_ROOT} must + // arrive as its own argv element. + if (cmd.includes('${PLUGIN_ROOT}')) { + out('FAIL', `${event}: command embeds ${PLUGIN_ROOT} ("${cmd}") - breaks on install paths with spaces`); + bad++; + } + if (!Array.isArray(h.args)) { + out('FAIL', `${event}: args is not an array (${JSON.stringify(h.args)}) - a joined string is not space-safe`); + bad++; + } else { + for (const a of h.args) { + if (typeof a === 'string' && /["']\s*\S+\s+.*\s*["']/.test(a)) { + out('FAIL', `${event}: args entry looks pre-quoted/joined ("${a}") - breaks on spaced paths`); + bad++; + } + } + } + } + } + if (bad === 0 && entries > 0) { + out('PASS', `hooks.json: all ${entries} hook entr(ies) use argv arrays (space-safe)`); + } + } + } + + // 11. 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. + // 5.1 decodes a BOM-less script using the system ANSI codepage, so a + // file containing non-ASCII comments is mis-decoded and the C# + // here-string in the detector stops being a here-string -- `using + // System;` is then parsed as PowerShell and the script dies before + // its first statement. + // + // .gitattributes pins `*.ps1 text eol=lf`, and its comment claims + // "LF avoids both failure modes". That is backwards: bare LF is part + // of the problem, and the guarantee is what propagates it. Either CRLF + // or a UTF-8 BOM fixes the parse (both verified against 5.1), but + // flipping .gitattributes to CRLF would re-break the Linux-side + // validator the same comment is trying to protect. The BOM is the + // narrower fix: it leaves the line-ending policy alone and is just + // three bytes at the head of the file. + // + // This was not hypothetical. Syncing the committed tree into the + // install directory via the documented `Copy-Item -Recurse -Force` + // flow installed a detector that could not start under 5.1 -- the + // only reason the running copy worked is that it had a BOM that the + // repository does not carry. + const ps1Files = []; + const walk = async (dir) => { + let entries; + try { entries = await readdir(dir, { withFileTypes: true }); } catch { return; } + for (const ent of entries) { + const abs = join(dir, ent.name); + if (ent.isDirectory()) { + if (ent.name === 'node_modules' || ent.name === '.git') continue; + await walk(abs); + } else if (ent.name.endsWith('.ps1')) { + ps1Files.push(abs); + } + } + }; + await walk(PLUGIN_ROOT); + + let missingBom = 0, nonAsciiCount = 0; + for (const abs of ps1Files) { + const buf = await readFile(abs); + const rel = abs.slice(PLUGIN_ROOT.length + 1); + const hasBom = buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf; + let nonAscii = false; + for (let i = 0; i < buf.length; i++) { + if (buf[i] > 0x7f) { nonAscii = true; break; } + } + if (!nonAscii) continue; // ASCII-only is safe without a BOM + nonAsciiCount++; + if (!hasBom) { + out('FAIL', `${rel}: contains non-ASCII but has no UTF-8 BOM (Windows PowerShell 5.1 cannot parse it)`); + missingBom++; + } + } + if (missingBom === 0) { + out('PASS', `ps1 encoding: all ${nonAsciiCount} non-ASCII .ps1 file(s) carry a UTF-8 BOM (${ps1Files.length} scanned)`); + } + finish(); }; diff --git a/plugins/antianqi/mcode-island/scripts/test-substep-progress.mjs b/plugins/antianqi/mcode-island/scripts/test-substep-progress.mjs new file mode 100644 index 00000000..ecb6ab8e --- /dev/null +++ b/plugins/antianqi/mcode-island/scripts/test-substep-progress.mjs @@ -0,0 +1,1416 @@ +// test-substep-progress.mjs — behavioral tests for the sub-step progress +// contract and the summary redaction rules. +// +// These are behavioral on purpose. The round-19 review (hetaoBackend) called +// out that the previous suite was "text-shaped": it asserted that a source +// file contained a given substring, so it could not tell a working +// implementation from a deleted code path wrapped in a comment. Every check +// below runs the real code. +// +// node scripts/test-substep-progress.mjs +// +// Exit 0 = all passed. Any failure exits 1. + +import { readFileSync, existsSync, mkdtempSync, writeFileSync, rmSync, mkdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { execFileSync, spawn } from 'node:child_process'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +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}`); } +function bad(name, why) { + fail++; + failures.push(`${name}: ${why}`); + console.log(`[FAIL] ${name}\n ${why}`); +} +function check(name, cond, why) { cond ? ok(name) : bad(name, why); } +function eq(name, actual, expected) { + const a = JSON.stringify(actual); + const e = JSON.stringify(expected); + a === e ? ok(name) : bad(name, `expected ${e}, got ${a}`); +} + +// --------------------------------------------------------------------------- +// PowerShell harness +// --------------------------------------------------------------------------- + +const PS = process.env.PS_BIN || (process.platform === 'win32' ? 'powershell.exe' : 'pwsh'); + +// A user-specific path is not a test result. Fall back to PATH the way a CI +// runner would, and skip loudly rather than silently passing. +function resolvePs() { + if (PS.includes('\\') || PS.includes('/')) { + if (existsSync(PS)) return PS; + } + for (const candidate of ['pwsh', 'powershell.exe', 'powershell']) { + try { + execFileSync(candidate, ['-NoProfile', '-Command', '$PSVersionTable.PSVersion.Major'], + { stdio: 'ignore' }); + return candidate; + } catch { /* try the next one */ } + } + return null; +} + +const PS_BIN = resolvePs(); + +// Scratch dir for the behavioral checks. Kept out of the repo so a failed run +// cannot leave fixtures behind for the next one to read. +const TMP = mkdtempSync(join(tmpdir(), 'island-substep-')); + +function runPs(script, 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 + // 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'], + 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); + } + 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 + // this repo uses the bare form. Requiring the paren made every lookup miss. + const start = src.search(new RegExp(`^function ${name}\\s*(\\(|\\{)`, 'm')); + if (start < 0) throw new Error(`function ${name} not found`); + const rest = src.slice(start); + // walk braces from the first one after the signature + const open = rest.indexOf('{'); + if (open < 0) throw new Error(`no opening brace in ${name}`); + let depth = 0; + for (let i = open; i < rest.length; i++) { + if (rest[i] === '{') depth++; + else if (rest[i] === '}') { + depth--; + if (depth === 0) return rest.slice(0, i + 1); + } + } + throw new Error(`unbalanced braces in ${name}`); +} + +/** Extract a `@{ ... }` hashtable assignment that starts at `from` lines in. */ +function extractTable(lines, startLine) { + const out = []; + for (let i = startLine; i < lines.length; i++) { + out.push(lines[i]); + if (/^\}/.test(lines[i])) return out.join('\n'); + } + throw new Error(`unterminated table at line ${startLine}`); +} + +function findLine(lines, re) { + const i = lines.findIndex((l) => re.test(l)); + if (i < 0) throw new Error(`pattern not found: ${re}`); + return i + 1; +} + +// --------------------------------------------------------------------------- +// 1. Secret redaction (round-19 review #1) +// --------------------------------------------------------------------------- + +// The canary is the whole point of this section. A test that only checks the +// source no longer contains `tool_input.text` would pass against an +// implementation that redacts by accident, or one where the redaction was +// replaced by a different leak. Plant a recognizable secret, run the real +// Format-ToolSummary, and assert the secret is absent from the output. +const CANARY = 'SUPER-SECRET-PASSWORD-8f3a91c2'; + +if (!PS_BIN) { + bad('PowerShell available', 'no pwsh/powershell on PATH; behavioral checks cannot run'); +} else { + const libPath = join(PLUGIN_ROOT, 'io.minimax.mcode', 'hooks', 'scripts', '_lib.ps1'); + const lib = readFileSync(libPath, 'utf8'); + + // Format-ToolSummary calls Protect-SecretText, which lives in a + // separate lib (round-20). Extracting the function body alone would + // leave the call unresolvable and the harness would die on an + // undefined command rather than on a real assertion -- so the harness + // dot-sources the lib, exactly as _lib.ps1 does in production. + const protectLibPath = join(PLUGIN_ROOT, 'scripts', 'lib', 'Protect-Text.ps1'); + + let formatToolSummary; + try { + formatToolSummary = extractFn(lib, 'Format-ToolSummary'); + } catch (e) { + bad('Format-ToolSummary is extractable', e.message); + } + + if (formatToolSummary) { + ok('Format-ToolSummary is extractable'); + + const harness = ` +$ErrorActionPreference = 'Stop' +. '${protectLibPath.replace(/'/g, "''")}' +${formatToolSummary} +$evt = [PSCustomObject]@{ + tool_name = 'mcode-computer-use' + tool_input = [PSCustomObject]@{ action = 'type'; coordinate = $null; text = '${CANARY}' } +} +Format-ToolSummary $evt +`; + const out = runPs(harness); + + // The secret must not appear anywhere in the output. + check('typed text is redacted: secret absent from summary', + !out.includes(CANARY), + `secret leaked into Format-ToolSummary output: ${JSON.stringify(out.trim())}`); + + // And the pill must still answer the useful question: is it typing? + check('typed text is redacted: action still reported', + /type/.test(out), + `expected the action name to survive redaction, got ${JSON.stringify(out.trim())}`); + + // Length is a safe signal to keep: it says nothing about content. + check('typed text is redacted: length is reported instead', + new RegExp(String(CANARY.length)).test(out) && /redacted/i.test(out), + `expected "<${CANARY.length} chars, redacted>"-style output, got ${JSON.stringify(out.trim())}`); + + // Negative control: a non-secret coordinate path must still work, or + // the redaction could have been implemented by disabling the branch. + const coordHarness = ` +$ErrorActionPreference = 'Stop' +. '${protectLibPath.replace(/'/g, "''")}' +${formatToolSummary} +$evt = [PSCustomObject]@{ + tool_name = 'mcode-computer-use' + tool_input = [PSCustomObject]@{ action = 'click'; coordinate = @(1024,768); text = $null } +} +Format-ToolSummary $evt +`; + const coordOut = runPs(coordHarness); + check('coordinate path is unaffected by redaction', + /1024,768/.test(coordOut), + `expected the coordinate to render as (1024,768), got ${JSON.stringify(coordOut.trim())}`); + } +} + +// --------------------------------------------------------------------------- +// 2. Sub-step rendering (round-19 review #4) +// --------------------------------------------------------------------------- + +const widgetPath = join(PLUGIN_ROOT, 'mcode-island.ps1'); +const widget = readFileSync(widgetPath, 'utf8'); +let buildDisplayMessage; +try { + buildDisplayMessage = extractFn(widget, 'Build-DisplayMessage'); + ok('Build-DisplayMessage is extractable'); +} catch (e) { + bad('Build-DisplayMessage is extractable', e.message); +} + +if (buildDisplayMessage && PS_BIN) { + const cases = [ + // [name, Message, Step, Total, Detail, expected] + ['step with total and detail renders "step 1/1 · detail"', + 'bash ok', 1, 1, 'ls -la /tmp', 'step 1/1 · ls -la /tmp'], + ['step without total renders "step 3 · detail"', + 'bash ok', 3, -1, 'build project', 'step 3 · build project'], + ['step with empty detail renders "step 3/12" only', + 'bash ok', 3, 12, '', 'step 3/12'], + ['no step falls back to the message (detail is NOT shown)', + 'bash ok', -1, -1, 'ls -la /tmp', 'bash ok'], + ['no step and no message renders empty', + '', -1, -1, 'ls -la', ''], + ]; + + for (const [name, msg, step, total, detail, expected] of cases) { + const harness = ` +$ErrorActionPreference = 'Stop' +${buildDisplayMessage} +Build-DisplayMessage -Message '${msg.replace(/'/g, "''")}' -Step ${step} -Total ${total} -Detail '${detail.replace(/'/g, "''")}' +`; + const out = runPs(harness).trim(); + eq(name, out, expected); + } +} + +// The review's #4 was that post-tool-use passes -Detail but no -Step, so +// Build-DisplayMessage's Step<=0 branch discards it. Prove the two sides +// actually connect: BOTH Push-Island calls must pass a Step, and the renderer +// must use it. +// +// Counting matters here. A single `[\s\S]*?` scan across the file passes as +// soon as ONE call carries -Step, so removing it from just the error branch +// left the check green -- a false green the negative-injection pass caught. +// Each call is matched on its own line and both must pass. +{ + const postTool = readFileSync( + join(PLUGIN_ROOT, 'io.minimax.mcode', 'hooks', 'scripts', 'post-tool-use.ps1'), 'utf8'); + + const calls = postTool.split('\n') + .map((l, i) => ({ line: l, n: i + 1 })) + .filter((x) => /^\s*Push-Island\b/.test(x.line)); + + check('post-tool-use has both Push-Island calls (error + done)', + calls.length === 2, + `expected 2 Push-Island calls, found ${calls.length}; a new branch was added without the sub-step arguments`); + + for (const call of calls) { + const label = /-State\s+error/.test(call.line) ? 'error' : 'done'; + check(`post-tool-use ${label} call passes -Step`, + /-Step\s+\$step\b/.test(call.line), + `the ${label} branch (line ${call.n}) calls Push-Island with -Detail but no -Step, so Build-DisplayMessage discards the detail`); + check(`post-tool-use ${label} call passes -Total`, + /-Total\s+\$total\b/.test(call.line), + `the ${label} branch (line ${call.n}) does not pass -Total, so the pill cannot render "step N/M"`); + } + + // Passing -Step is necessary but not sufficient. If the local $step/$total + // are ever set to the "unset" sentinel (-1, which is what both + // notify-island.ps1 and Build-DisplayMessage treat as absent), the flag + // check above still passes while the detail is discarded again at render + // time. A negative-injection pass caught exactly this: setting + // `$step = -1` left every assertion green. + const stepAssign = postTool.match(/^\$step\s*=\s*(-?\d+)\s*$/m); + const totalAssign = postTool.match(/^\$total\s*=\s*(-?\d+)\s*$/m); + + check('post-tool-use assigns a positive $step', + !!stepAssign && Number(stepAssign[1]) > 0, + stepAssign + ? `$step is ${stepAssign[1]}, which is the "unset" sentinel; the pill will discard the detail at render time` + : 'post-tool-use.ps1 never assigns $step, so -Step $step forwards an undefined value'); + + check('post-tool-use assigns a positive $total', + !!totalAssign && Number(totalAssign[1]) > 0, + totalAssign + ? `$total is ${totalAssign[1]}, which is the "unset" sentinel; the pill cannot render "step N/M"` + : 'post-tool-use.ps1 never assigns $total, so -Total $total forwards an undefined value'); +} + +// --------------------------------------------------------------------------- +// 3. 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, "''"); + +if (PS_BIN) { + const detectPath = join(PLUGIN_ROOT, 'mcode-status-detect.ps1'); + const detect = readFileSync(detectPath, 'utf8'); + const detectCode = stripPsComments(detect); + const lines = detectCode.split('\n'); + + const writeStatusFn = extractFn(detect, 'Write-Status'); + 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. + // + // 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' +. '${storeLibRef}' +$S_IDLE = 'idle' +$S_DETECTOR = 'detector' +$script:plan5hRemainingPct = $null +$script:plan5hResetMs = $null +$script:plan5hTodoData = $null +${writeStatusFn} +${writeStatusUnderLockFn} +${call} +'DONE' +`; + + 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); +} + +// --------------------------------------------------------------------------- +// 4. SWP_NOMOVE on the restore path (review #3) +// --------------------------------------------------------------------------- + +{ + 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-Window'); + + if (zorderCall) { + // Pull the flags expression that feeds this specific call. Anchor on + // the assignment and require the HWND_TOP call to be the very next + // SetWindowPos: a broad `[\s\S]*` scan would happily pick up flags + // belonging to the earlier work-area call and pass regardless. + const flagsExpr = (toggle.match(/\$nofollow\s*=\s*([^\n\r]+)/) || [null, ''])[1]; + check('restore z-order flags include SWP_NOMOVE', + /SWP_NOMOVE/.test(flagsExpr), + 'the restore path omits SWP_NOMOVE, so X=0/Y=0 moves the window to (0,0) on any monitor whose origin is not 0'); + + check('restore z-order flags include SWP_NOSIZE', + /SWP_NOSIZE/.test(flagsExpr), + 'the restore path omits SWP_NOSIZE, so cx=0/cy=0 resizes the window to 0x0'); + + check('restore z-order flags do NOT include SWP_NOZORDER', + !/SWP_NOZORDER/.test(flagsExpr), + 'the restore path passes HWND_TOP together with SWP_NOZORDER, which makes Windows ignore hWndInsertAfter and defeats the z-order call'); + } +} + +// --------------------------------------------------------------------------- +// 5. A detector state-inference write must CLEAR a stale sub-step +// (round-20 #5) +// --------------------------------------------------------------------------- +// +// Section 3 pins the other half of the same contract: a metadata refresh +// must PRESERVE step/total/detail, because the 60s 5h-usage rewrite and +// the todo rewrite restate the current state rather than announcing a new +// step. Preserving unconditionally is only half right. +// +// A detector state-inference write is different in kind. It read a new +// tool call out of the session log and is asserting "the agent is doing +// THIS now". That message has no relationship to whatever sub-step a hook +// pushed last, so inheriting step/total/detail leaves the previous turn's +// counter on the pill indefinitely. Observed live: the pill sat on +// "step 1/1" showing a curl command from a finished tool call, through +// several subsequent turns, because nothing ever cleared it. +// +// The two write paths must therefore be distinguished explicitly, not by +// guessing from the message text. +// +// 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 buildHarness = (call) => ` +$ErrorActionPreference = 'Stop' +. '${storeLibRef}' +$S_IDLE = 'idle' +$S_DETECTOR = 'detector' +$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' +`; + + // 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|'); +} + +// --------------------------------------------------------------------------- +// 6. The pill never renders raw tool JSON (round-20 #6) +// --------------------------------------------------------------------------- +// +// The detector's `running` message used to be built as +// "$verb " + (ConvertTo-Json $args -Compress) truncated to 60 chars +// which put this on screen: +// +// Running {"command":"$ErrorActionPreference=\u0027Continue\u0027\n... +// +// Escaped quotes, a JSON key, and a truncation that can land mid-token. +// `skill` is not in $TOOL_ACTIONS / $TOOL_FAMILIES, so it always took that +// JSON path -- meaning one of the most frequent tools on screen was the +// least readable. +// +// The contract: a human-readable field, never the serialised argument +// object. Unknown tools resolve to the verb alone rather than dumping +// JSON, because "Using" is a fine pill and "Using {\"name\":...}" is not. +{ + const detectPath = join(PLUGIN_ROOT, 'mcode-status-detect.ps1'); + const detect = readFileSync(detectPath, 'utf8'); + + let formatArgs; + try { + formatArgs = extractFn(detect, 'Format-ToolArgs'); + } catch (e) { + bad('Format-ToolArgs is extractable', e.message); + } + + if (!formatArgs) { + bad('tool summaries are human-readable, not raw JSON', + 'Format-ToolArgs not found: the detector still renders ConvertTo-Json output'); + } else { + ok('Format-ToolArgs is extractable'); + + const protectLibPath = join(PLUGIN_ROOT, 'scripts', 'lib', 'Protect-Text.ps1'); + const harness = ` +$ErrorActionPreference = 'Stop' +. '${protectLibPath.replace(/'/g, "''")}' +${formatArgs} +$cases = ConvertFrom-Json $env:ISLAND_TEST_CASES +foreach ($c in $cases) { + # $c.a is already a PSCustomObject -- the outer ConvertFrom-Json turned + # the nested object into one. Re-parsing it fails on the '@'. + $r = Format-ToolArgs $c.t $c.a + '{0}={1}' -f $c.n, $r +} +`; + + const cases = [ + { n: 'bash', t: 'bash', a: { command: 'npm test' }, want: 'npm test' }, + // round-20 #8: a leading `$var = ...` preamble is agent boilerplate, + // not the work. The first *executed* line is what the user wants + // to read on the pill. + { n: 'bashMulti', t: 'bash', a: { command: "$x = 'Stop'\nGet-ChildItem" }, want: 'Get-ChildItem' }, + // Every line an assignment: nothing to fall through to, so the + // first line must still render rather than the pill going blank. + { n: 'bashAllVar', t: 'bash', a: { command: "$a = 1\n$b = 2" }, want: '$a = 1' }, + // Comments are boilerplate too. + { n: 'bashComment', t: 'bash', a: { command: "# setup\nnpm run build" }, want: 'npm run build' }, + { n: 'read', t: 'read', a: { file_path: 'C:\\proj\\a\\file.ts' }, want: 'file.ts' }, + { n: 'write', t: 'write', a: { file_path: 'C:\\proj\\a\\out.json' }, want: 'out.json' }, + { n: 'edit', t: 'edit', a: { file_path: 'C:\\proj\\a\\x.ps1' }, want: 'x.ps1' }, + { n: 'grep', t: 'grep', a: { pattern: 'TODO' }, want: 'TODO' }, + { n: 'glob', t: 'glob', a: { pattern: '**/*.ps1' }, want: '*.ps1' }, + { n: 'websearch', t: 'web_search', a: { query: 'mcode changelog' }, want: 'mcode changelog' }, + { n: 'webfetch', t: 'web_fetch', a: { url: 'https://example.com/y' }, want: 'example.com' }, + // skill is absent from the tool taxonomy: this is the case + // that was guaranteed to render as raw JSON. + { n: 'skill', t: 'skill', a: { name: 'docx' }, want: 'docx' }, + { n: 'taskout', t: 'task_output', a: { task_id: 'bg_abc123' }, want: 'bg_abc123' }, + // Unknown tool with an unrecognised shape: verb-only, not JSON. + { n: 'unknown', t: 'wibble', a: { zzz: 1 }, want: '' }, + // Credential in a command must still be redacted on this path. + { n: 'secret', t: 'bash', a: { command: "curl -H 'Authorization: Bearer eyJhbGciOi.SUPERSECRET'" } }, + ]; + + const out = runPs( + `$env:ISLAND_TEST_CASES = '${JSON.stringify(cases).replace(/'/g, "''")}'\n` + harness); + const got = {}; + for (const line of out.split(/\r?\n/)) { + const m = line.match(/^(\w+)=(.*)$/); + if (m) got[m[1]] = m[2]; + } + + for (const c of cases) { + if (!(c.n in got)) { + bad(`summary ${c.n}`, `no output produced (got ${JSON.stringify(out.trim())})`); + continue; + } + const v = got[c.n]; + // The blanket rule, asserted for every case including the + // secret one: nothing JSON-shaped reaches the pill. + const jsonish = /\{\s*"|":\s*"|\\u00[0-9a-f]{2}/i.test(v); + if (jsonish) { + bad(`summary ${c.n}`, `renders raw JSON: ${JSON.stringify(v)}`); + continue; + } + if (c.want === '') { + // Unknown tool with nothing worth showing: must be empty, + // not merely "not JSON". A partial dump is still a dump. + if (v !== '') { + bad(`summary ${c.n}`, `expected empty, got ${JSON.stringify(v)}`); + continue; + } + } else if (c.want !== undefined) { + if (!v.toLowerCase().includes(String(c.want).toLowerCase())) { + bad(`summary ${c.n}`, `expected to contain ${JSON.stringify(c.want)}, got ${JSON.stringify(v)}`); + continue; + } + } + if (c.n === 'secret' && /SUPERSECRET/.test(v)) { + bad('summary secret', `credential survived: ${JSON.stringify(v)}`); + continue; + } + ok(`summary ${c.n}${c.want !== undefined ? ` -> ${JSON.stringify(v)}` : ' (redacted)'}`); + } + + // A multi-line command must not spill its body onto a one-line pill, + // and the boilerplate preamble must not be what the user reads. + if ('bashMulti' in got && /\$x/.test(got.bashMulti)) { + bad('summary bashMulti', 'the variable-assignment preamble was shown instead of the first executed line'); + } else if ('bashMulti' in got) { + ok('summary bashMulti skips the assignment preamble'); + } + } +} + +// --------------------------------------------------------------------------- +// 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'); +} + +// --------------------------------------------------------------------------- +// 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. +// +// 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. + * 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 (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'); + + 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'); + } +} + +// --------------------------------------------------------------------------- +// 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'); + } +} + +// --------------------------------------------------------------------------- +// 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'); + } + } +} + +// --------------------------------------------------------------------------- +// 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 +// --------------------------------------------------------------------------- + +try { rmSync(TMP, { recursive: true, force: true }); } catch { /* best effort */ } + +console.log('-'.repeat(60)); +console.log(`${pass} pass, ${fail} fail${skipped ? `, ${skipped} skipped (platform)` : ''}`); +if (fail > 0) { + console.log('\nfailures:'); + for (const f of failures) console.log(` - ${f}`); + process.exit(1); +} diff --git a/plugins/antianqi/mcode-island/scripts/test-windows-workflow-local.ps1 b/plugins/antianqi/mcode-island/scripts/test-windows-workflow-local.ps1 index f4ab6496..b6ec7205 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: @@ -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 ---------------------------------------------------- @@ -78,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() @@ -85,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" } @@ -97,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 --------------------------------------- @@ -204,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 @@ -265,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" @@ -273,5 +311,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 diff --git a/plugins/antianqi/mcode-island/set-token.ps1 b/plugins/antianqi/mcode-island/set-token.ps1 index 2e28b579..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 +# 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' diff --git a/plugins/antianqi/mcode-island/skills/SKILL.md b/plugins/antianqi/mcode-island/skills/SKILL.md index 6ab1c982..39ad668c 100644 --- a/plugins/antianqi/mcode-island/skills/SKILL.md +++ b/plugins/antianqi/mcode-island/skills/SKILL.md @@ -26,9 +26,25 @@ 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 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). +`mcode-island unpin` clears the pin. ## Two ways to drive the pill @@ -137,10 +153,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 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. ## User-side management @@ -149,8 +166,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 +189,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 | @@ -180,7 +198,7 @@ All widget state lives under `%APPDATA%\mcode-island\`: No data leaves the local machine *unless* an opt-in 5-hour usage token is configured. See the **Network access** + **Accounts** sections in -`README.md` for the exact host (`api.minimax.io/v1/coding_plan/remains`), +`README.md` for the exact host (`api.minimaxi.com/v1/coding_plan/remains`), the rate limit (one GET per 60 s), and the storage locations (`config.json:planApiToken` or env `MINIMAX_OAUTH_TOKEN` / `MINIMAX_API_KEY`). When no token is configured the plugin makes no network requests at all. diff --git a/plugins/antianqi/mcode-island/skills/mcode-island/SKILL.md b/plugins/antianqi/mcode-island/skills/mcode-island/SKILL.md index 6ab1c982..1d42796e 100644 --- a/plugins/antianqi/mcode-island/skills/mcode-island/SKILL.md +++ b/plugins/antianqi/mcode-island/skills/mcode-island/SKILL.md @@ -26,9 +26,25 @@ 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 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). +`mcode-island unpin` clears the pin. ## Two ways to drive the pill @@ -130,6 +146,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. @@ -137,10 +180,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 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. ## User-side management @@ -149,8 +193,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 +216,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 | @@ -180,7 +225,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/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 {