diff --git a/.github/workflows/mcode-island-windows.yml b/.github/workflows/mcode-island-windows.yml index 2445f47d..66e8af65 100644 --- a/.github/workflows/mcode-island-windows.yml +++ b/.github/workflows/mcode-island-windows.yml @@ -135,14 +135,20 @@ 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 encrypted now (DPAPI, CurrentUser scope), and + # the plaintext key must be gone: a stale plaintext copy is exactly + # the leak that change removed. + if (-not ($cfg.PSObject.Properties['planApiTokenEnc'] -and $cfg.planApiTokenEnc)) { + throw "set: config.json has no planApiTokenEnc (got: $($cfg.planApiTokenEnc))" + } + if ($cfg.PSObject.Properties['planApiToken']) { + throw "set: config.json still carries the plaintext planApiToken" } # 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,8 +159,8 @@ 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['planApiToken']) { - throw "clear: planApiToken still present in config.json" + if ($cfgAfter.PSObject.Properties['planApiToken'] -or $cfgAfter.PSObject.Properties['planApiTokenEnc']) { + throw "clear: a token field is still present in config.json" } # 2d) show: verify it reports the unconfigured state @@ -411,3 +417,86 @@ 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" + } + + # The session list (v0.5.0) brought a 500-line suite of its own, and + # it was in no workflow: the feature's core tests only ran when + # somebody remembered to run them by hand. A suite that is not wired + # into CI is a suite that rots. + # + # Round-24 review (hetaoBackend, 2026-11-04): this suite now also + # carries the session-traversal boundary regression. It builds a real + # junction pointing at a directory OUTSIDE the sessions root, puts a + # session there, and proves the outside session never reaches the list + # -- and it proves the fixture itself still fools the old blind + # recursion, so the assertion cannot pass for the wrong reason. A + # junction is a Windows reparse point, so this case belongs to this + # windows-latest job and not to the ubuntu one. + - name: Session tracker suite + run: | + [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + $OutputEncoding = [System.Text.Encoding]::UTF8 + try { chcp 65001 | Out-Null } catch {} + + $suite = 'plugins/antianqi/mcode-island/scripts/test-session-tracker.ps1' + if (-not (Test-Path $suite)) { + throw "$suite not found (cwd: $((Get-Location).Path))" + } + Write-Host "Suite: $((Resolve-Path $suite).Path)" + + & $suite + if ($LASTEXITCODE -ne 0) { + throw "test-session-tracker.ps1 failed with exit code $LASTEXITCODE" + } diff --git a/.gitignore b/.gitignore index cc385d9f..89e476af 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,6 @@ credentials.json *credentials.json *.pem *.p12 + +# Stale widget backups from debugging sessions; never part of a build. +*.bak-r16 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..df9a8a70 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 @@ -216,6 +269,12 @@ mcode-island/ │ ├── subagent-stop.ps1 │ ├── permission-request.ps1 │ └── permission-denied.ps1 +├── scripts/ # test suites (not needed at runtime) +│ ├── smoke.mjs # static + material checks +│ ├── test-material-injection.mjs # negative-injection harness +│ ├── test-session-tracker.ps1 # detector / redaction checks +│ ├── test-substep-progress.mjs # sub-step progress checks +│ └── lib/Session-Tracker.ps1 # shared detector logic ├── skills/mcode-island/SKILL.md # Skill consumed by the agent └── assets/ # screenshots embedded above ``` @@ -232,7 +291,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 +300,71 @@ 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 | -| `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") | +| `sessions.json` | `%APPDATA%\mcode-island\` | rewritten every second | the session list (titles, previews, states) | +| `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, material pole | +| `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\`. + +**The session list reads the session logs the runtime already writes on +this machine** (`/v2/sessions/**/messages.jsonl`): the first user +message of a session for its title, the last message for its preview and +state, and the log's mtime for its liveness. Titles and previews are +truncated and redacted before they are written to `sessions.json` or drawn +on screen. The runtime's own database is not read, and the MiniMax Code +programme is not used to run anything. ## 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 +384,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 +412,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,21 +424,293 @@ 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 re-raise the hidden window: `mcode-island show`. To kill it for real: `mcode-island stop`. +## The session list + +Resting the mouse on the pill expands a list of **every session that is +currently live**, not just the one the detector happens to be following. +The detector follows a single session — the one that wrote its log most +recently — but on a machine with a CLI session, a desktop-app session and +a couple of side agents, the interesting thing is usually the *other* one. + +``` + ● 剪映插件开发 3 分钟前 + 执行 bash 运行中 +``` + +Each row carries four things: + +| part | where it comes from | +| --------- | ------------------- | +| status dot | the same state vocabulary the pill uses, same color | +| title | the first thing the user said in that session, truncated to 40 characters | +| preview | the last thing that session said, truncated to 120 characters | +| timestamp | how long ago that session last moved, plus its state word | + +**What is read, and what is not.** The list is fed by the detector, which +reads the session logs the runtime already writes on this machine: + +- **the first user message** of a session — its title; +- **the last message** of a session — its preview and its state; +- **the log's mtime** — how long ago the session moved, and whether it is + still alive. + +Nothing else is read. In particular the widget does **not** read the +runtime's own database, and it does **not** borrow the MiniMax Code +programme to run anything: the title is simply the first thing the user +said, which is what the app itself shows in its own sidebar. + +**Truncated and redacted before it is stored or shown.** A title and a +preview are things a user said — they can contain a key. Both are cut to a +fixed length and passed through the same secret redactor the plugin uses +for log output **before** they are written to `sessions.json` and before +they are drawn on screen. + +**The state words are the pill's words.** The list does not invent its +own vocabulary: `working` / `thinking` / `waiting` / `idle` / `done` / +`error` are rendered with the same text and the same color as on the +pill, so the two places never disagree. The one addition is **已停止** +("stopped"), which is not a message type but a liveness fallback: a +session that has not moved for five minutes is no longer "running", +even though nothing said otherwise. + +**The list is cleaned up.** The list is "the sessions that are alive +now", not a history. A session that has not moved for 30 minutes is +dropped from the list and from `sessions.json`; a session that moves +again comes back. Without that, a morning of twenty conversations would +leave twenty rows on the pill and twenty conversations' worth of text in +the state file. + +**Clicking a row goes back to that session — it never opens a second +one.** The widget first looks for the Windows Terminal tab that runs it +and selects that tab. If the session lives in the desktop app instead, +the widget switches the app to that conversation: the app's sidebar row +is a button whose accessible name is the session name, so the widget +finds the sidebar's conversation list and invokes the row there (the +search is scoped to that list, not to the whole window). If neither +matches, the widget raises the terminal window rather than starting a new +`mcode --session `. A second terminal for the same session is exactly +the bug this feature is meant to avoid. + +Clicking a row also dismisses it: the session leaves the list and stays +out of it until it starts its next round. + +**The × on each row dismisses it without going anywhere.** Clicking the +row body means "take me back to that session". The × means "I am done with +this one" — and those are different intentions. Sometimes the rows in the +list are conversations you have already dealt with, and you do not want to +be dragged out of whatever you are doing to get rid of them. + +So the × dismisses the row and nothing else: + +- it does **not** switch to the session, and does not open a terminal; +- it does **not** close the list — the row disappears, the list shrinks, + and the next row's × is right there, so several rows can be dismissed in + a row; +- it **gives the focus back** to the window that had it. Pressing the + mouse button hands the focus to the pill; for "jump to that session" + that is fine, but for "just dismiss this" it would cost the user their + editor's focus. The widget captures the foreground window when the + mouse enters the pill and hands it back afterwards. + +Dismissal uses the same rule as a row click: the session stays out of the +list until it starts its next round, then it comes back on its own. + +The list is fed by the detector, which writes `sessions.json` next to +`status.json` in `%APPDATA%\mcode-island\`. That file is the contract +between the detector and the widget; the widget only reads it. + +**The list is bounded.** Three limits, on purpose: + +- the detector keeps at most **20** sessions (`MAX_SESSIONS`), dropping the + ones that have been idle the longest; +- the widget draws at most **12** rows (`MAX_SESSION_ROWS`) and says + "还有 N 条" underneath, rather than silently dropping them — a row that + vanished without a word would be read as "that is all there is", which + is the exact question the list exists to answer; +- `sessions.json` itself is capped at **64 KB** (`MAX_SESSIONS_JSON_BYTES`). + The row cap does not bound the file: every row also carries its title + history, and the dismissed map has no cap at all — so the write path + measures the serialized bytes and drops the oldest rows until it fits. + +The list height is also clamped to the work area, so it can never grow +past the screen. + +## The session list's file boundary + +The tracker and the detector both walk the sessions tree to find the session +logs. That walk is **bounded**: it never uses `SearchOption.AllDirectories` +— which, per Microsoft's own documentation, *follows reparse points* +(symbolic links, junctions, mounted volumes) — and it refuses any candidate +that is a reparse point or that does not stay under the canonical sessions +root. A junction under the sessions root therefore cannot hand the widget a +log from somewhere else on the machine. + +`scripts/test-session-tracker.ps1` proves it on Windows: it builds a real +junction pointing at a directory outside the sessions root, puts a session +there, and checks that the session never reaches the list — and that the old +blind recursion *would* have found it, so the assertion cannot pass for the +wrong reason. + +## The material — Liquid Glass + +The pill is not a translucent rectangle. It is a **real frosted material** +built to Apple's design language: [Human Interface Guidelines — +Materials](https://developer.apple.com/design/human-interface-guidelines/materials) +and [Color](https://developer.apple.com/design/human-interface-guidelines/color), +as presented at WWDC25 (*Meet Liquid Glass*). + +The rules those documents set, and how the widget obeys them: + +| guideline | what the widget does | +| --------- | -------------------- | +| "Liquid Glass has no inherent color, and instead takes on colors from the content directly behind it" | the glass body starts as `Transparent` and is painted at runtime from a live screen capture; no hard-coded tint survives in the source | +| "blurs and adjusts the luminosity of background content to maintain legibility" | the backdrop is captured and blurred, and the glass is mixed towards a **target grey** that keeps the text legible whatever is behind it | +| "Liquid Glass appears more opaque in larger elements" | the expanded panel converges on a *darker* target grey than the pill, so the large element reads as a thicker slab of the same material | +| specular highlights scale with geometry | a one-pixel rim light on the top edge, a darker inner edge below it, and a soft drop shadow under the whole body | + +**The material layers paint; they do not measure.** The four material +paths (`Tint`, `Grain`, `Rim`, `Edge`) are drawn with a geometry that +covers the whole glass body, and a WPF `Path` measures as big as its `Data`. +Inside the layout they would hold the panel at the height of the *previous* +frame: the expanded panel sits in an `Auto` row, so its height would be +`max(last material rectangle, content height)` — the content could shrink +and the panel would not, and every open made the black slab a little taller +until it ran off the bottom of the screen. The layers therefore live in a +`Canvas`, which does not measure its children: they paint, and nothing +else. + +**How the frost is made.** The widget grabs the desktop behind itself +(`SetWindowDisplayAffinity(WDA_EXCLUDEFROMCAPTURE)` so the capture never +includes the pill), downsamples it 4×, applies a WPF `BlurEffect`, and scales +it back up — 24 physical pixels of blur radius, computed in physical pixels. +A ≤3 % noise grain rides on top, so the frost has the slight tooth of a real +material instead of the plastic look of a clean Gaussian. + +**One material: black glass, whatever is behind it.** The rule that +decides everything is HIG's *"adjusts the luminosity of background content"*. +The widget does not lay a translucent plate over the screen; it **solves for +a target grey** and mixes the backdrop towards it: + + behind is brighter than the target -> mix in black: veil = 1 - target / L + behind is darker than the target -> mix in white: lift = (target - L) / (1 - L) + +The dark pole targets **0.115** (byte 29 ≈ `#1D1D1D`, the black macOS's +Dynamic Island uses), so the glass lands on the same grey whether it sits on +a white document or on a black terminal. That is the *unified* look: the +island is one black slab, and only the frost shows what is behind it. + +The earlier build mixed in a *fixed* proportion of black instead. Over a +white page the glass then settled at byte 106 (≈ `#6A6A6A`) — a mid grey +— and the dark labels on top of it turned to mud. Solving for a target +instead of guessing a wash is what removes the grey. + +| pole | target grey | content | used when | +| ---- | ----------- | ------- | --------- | +| `dark` (default) | 0.115 — island black | white content, Apple dark-mode system colours | always, unless pinned otherwise | +| `light` | 0.90 — near-white | dark content, macOS-style black-transparent labels | pinned explicitly | + +The pale pole is still there for people who want the island to blend into a +light document, and it now converges on near-white the same way. The pole +switch has **hysteresis** — bright past 0.60 to go light, dark past 0.58 to +go back — so a background flickering near the threshold does not make the +pill strobe. + +**Each surface picks its own pole.** The pill and the expanded panel are two +separate pieces of glass, and what is behind them can be wildly different — +the pill can sit on a bright title bar while the panel below it covers a +dark editor. So the polarity, the foreground colours and the grain are +decided **per surface**, not once for the whole widget: each glass body +measures its own backdrop and picks its own pole. + +This is not a detail. An earlier build shared one global polarity, and the +panel's decision overwrote the pill's: the pill ended up as dark glass with +dark text, and the two surfaces flipped each other every 100 ms — the widget +looked broken. HIG "Materials" says the same thing in one line: each material +adjusts its own luminosity to keep what is written on it legible. + +**Reading the log.** `widget.log` keeps one line per surface, so a polarity +question is answerable without a debugger: + +``` +GLASS PILL POLE -> light # the pill switched poles +GLASS Pill L=0.837 pole=light # ...at this backdrop brightness +GLASS PANEL POLE -> dark # the panel decided separately +GLASS Panel L=0.528 pole=dark +``` + +The luminance slot is per surface too. Sharing one slot between the two +surfaces meant the brightness line was almost never written for the second +surface — and "why did the panel go white?" became unanswerable, because the +panel's pole change was the only trace of it in the log. + +**Contrast is guaranteed, not hoped for.** All the material constants are +named (`$script:glassTargetByteDark`, `$script:glassThickDark`, …) rather +than buried in expressions, so the smoke suite reads them, re-derives the +worst case over the real backdrop, and recomputes the WCAG ratio. Current +worst case: **16.57 : 1** primary text on the dark pole, **10.56 : 1** on the +light pole, with secondary text at **5.86 : 1** / **3.88 : 1**. + +Converging on a target grey is what buys those numbers. Because the glass +lands on the same grey over *every* backdrop, the contrast is a property of +the material, not of whatever happens to be behind it — which is also why +the old "secondary text below WCAG AA on a bright backdrop" trade-off is +gone: on the dark pole the secondary text now clears 4.5 : 1 as well. + +**Configuration.** `materialPole` in `config.json`: + +| value | meaning | +| ----- | ------- | +| `dark` (default) | the unified black glass, whatever is behind it | +| `light` | the pale glass with dark content | +| `auto` | switch between the two from the measured backdrop brightness | + +The default is `dark`, because that is what the Dynamic Island does on +macOS: it does not change colour with the page underneath it. `auto` is +there for people who want the island to blend into a light document, and +`light` to pin the pale material outright. + +**Known trade-off.** `SetWindowDisplayAffinity` makes the widget invisible to +screen recorders for the ~10–30 ms each capture window (every 2.5 s). A +recording of the screen may occasionally drop a frame of the pill. + ## Test evidence This plugin has been exercised on Windows 11 24H2 with PowerShell 5.1, against @@ -343,11 +723,74 @@ 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). +- Per-row × verified on a live machine with real mouse input: the row is + removed from `sessions.json` within a second, the list stays open and + shrinks by one row, the foreground window is unchanged before and after + the click (`pid` and `hwnd` identical), and a second × dismisses a second + row — two dismissals in one second both land, which is what the + multi-id `ack.signal` is for. +- 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 through mcode's own bash tool and passes the exit code to `wrap-tool.ps1 -ExitCode`. +- Material engine verified against a deterministic white backdrop on a live + machine: the island renders as **black glass** over the white page — not + the mid grey (`#6A6A6A`) the fixed-wash model produced — with white text + and a visible frost. Pixel-sampled on that screenshot: a blue shape behind + the glass comes through as `rgb(15,29,43)`, i.e. 11.5 % of `rgb(0,122,255)` + — the frost transmits the backdrop while keeping its hue, which is the + whole point of adjusting luminance instead of washing grey over it. +- The same island over a dark terminal is the same black slab: the terminal's + clock and text come through the panel blurred, so the frost still reads + while the material stays black. +- The **split backdrop** case verified on a live machine: a bright strip + under the pill and the dark terminal under the panel, each surface reading + its own backdrop and picking its own pole — simultaneously, with no flicker + in the log (`Pill L=0.837 pole=light` / `Panel L=0.528 pole=dark`, stable over + several refresh cycles). + The harness proves the stacking before it grabs anything + (`WindowFromPoint` at the pill must resolve to the widget process, and the + island is raised with `SetWindowPos`/`HWND_TOPMOST` first) — without that + check the bright strip can end up *on top of* the island, in which case the + screenshot shows the strip's own crisp text instead of glass and the run + looks like it passed while proving nothing. +- The material assertions are **negative-injection tested**: + `scripts/test-material-injection.mjs` mutates one line of a **copy** of the + source 44 times (a hard-coded tint coming back, the capture affinity call + removed, the measure/arrange step skipped, the grain opacity raised, the two poles' + colour tables diverged, the hysteresis removed, the panel made thinner + than the pill, the two surfaces sharing one polarity again, the luminance + log slot going back to one shared value, the convergence formula replaced + by a fixed wash, the thickness factor shadowing the typed `$Thick` + parameter, an XML comment left unclosed in the XAML, the XAML block + unreadable, the comment strip itself broken, the strip knowing only one of + the two HTML comment end tags, the strip advancing by the wrong length, the dismiss mark no longer focusable, the keyboard handler never attached, the keyboard handler no longer dismissing, the keyboard handler dropping Space, the keyboard handler dropping Return, …), + checks that + the expected assertion turns red each time, and confirms the suite is + green again after the source is restored. The copy is not an optimisation: + the harness used to rewrite the checked-in file in place, and `node --test` + runs every suite in the repository in parallel -- a sibling suite that read + the widget mid-write saw a half-written file and reported a function that + is in the source as missing. A watcher now re-reads the working-tree + sources for the whole run and fails if any of them moved, so that race + cannot come back. +- The XAML is stripped of its XML comments **before** the material + assertions read it, so a comment in the source cannot feed them. The strip + walks the text instead of running a regex: a paired comment is dropped + whole, an unclosed `` and `--!>`) and advances by the tag's real length, so neither form + leaves a character behind. Three assertions say that out loud (every + comment in the XAML is closed; the stripped XAML carries no marker left; + the strip recognises every HTML comment end tag -- checked against a + probe, because the widget's own XAML never uses `--!>`), and a fourth + reports when the XAML block cannot be read at all. ## Limitations 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..10b9f8d7 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)" @@ -49,6 +74,8 @@ try { Add-Type -AssemblyName PresentationCore Add-Type -AssemblyName WindowsBase Add-Type -AssemblyName System.Xaml + # 抓屏(CopyFromScreen)要用 System.Drawing 的 Bitmap/Graphics。 + Add-Type -AssemblyName System.Drawing Dbg 'WPF loaded' } catch { Dbg "WPF LOAD FAIL: $($_.Exception.Message)" @@ -71,11 +98,71 @@ public class WinAPI { [DllImport("user32.dll")] public static extern int GetWindowText(IntPtr hWnd, System.Text.StringBuilder lpString, int nMaxCount); [DllImport("user32.dll")] public static extern int GetWindowTextLength(IntPtr hWnd); [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId); + [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); [DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc lpEnumFunc, IntPtr lParam); [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr hWnd); + [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect); + [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); + // WDA_EXCLUDEFROMCAPTURE:把这个窗口从屏幕捕获里排除(抓屏时它变成透明)。 + // 抓自己的屏幕来做磨砂时必须用它 —— 否则抓到的是"上一帧的自己",模糊 + // 之后会是一团脏影子。 + [DllImport("user32.dll")] public static extern bool SetWindowDisplayAffinity(IntPtr hWnd, uint dwAffinity); + [DllImport("gdi32.dll")] public static extern bool DeleteObject(IntPtr hObject); + [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) { @@ -109,11 +196,15 @@ $defaultConfig = [PSCustomObject]@{ height = 70 opacity = 0.95 autostart = $false + # 材质极性:dark(默认)= 统一的黑玻璃;light = 钉死浅色玻璃; + # auto = 按背后亮度在两极之间自动切。 + # 见下面"材质的两极"那一节。 + materialPole = 'dark' } if (Test-Path $configFile) { try { $cfg = Get-Content $configFile -Raw -Encoding UTF8 | ConvertFrom-Json } catch { $cfg = $defaultConfig } - foreach ($p in @('x','y','width','height','opacity','autostart')) { + foreach ($p in @('x','y','width','height','opacity','autostart','materialPole')) { if (-not (Get-Member -InputObject $cfg -Name $p -ErrorAction SilentlyContinue)) { Add-Member -InputObject $cfg -NotePropertyName $p -NotePropertyValue $defaultConfig.$p } @@ -123,6 +214,11 @@ if (Test-Path $configFile) { $cfg | ConvertTo-Json | Out-File -FilePath $configFile -Encoding UTF8 } +# 窗口比玻璃体大一圈的留白(投影要画在这圈里)。 +$script:glassMargin = 18 +# 面板比药丸厚,投影更远更重,下留白要多给一点。 +$script:glassMarginPanelBottom = 24 + # XAML —— 灵动岛 pill $xaml = @' - + + + + + + + + + - + - + + + + + + + + + + + - - + + 0.5,0.5 - + + Fill="#FF8E8E93"> + + + + @@ -166,29 +330,29 @@ $xaml = @' - - + @@ -197,7 +361,7 @@ $xaml = @' + Fill="#FF0A84FF"> @@ -214,10 +378,50 @@ $xaml = @' + + + + + + + + + + + + + + + + + + + + '@ @@ -241,6 +445,7 @@ $messageText = $window.FindName('MessageText') $actionIcon = $window.FindName('ActionIcon') $pill = $window.FindName('Pill') $progressBar = $window.FindName('ProgressBar') +$progressTrack = $window.FindName('ProgressTrack') $progressFill = $window.FindName('ProgressFill') $progressScale = $window.FindName('ProgressScale') $progressIndeterminate = $window.FindName('ProgressIndeterminate') @@ -248,15 +453,2033 @@ $progressShimmer = $window.FindName('ProgressShimmer') $progressShimmerTransform = $window.FindName('ProgressShimmerTransform') $elapsedText = $window.FindName('ElapsedText') $usage5hText = $window.FindName('Usage5hText') +$statusDotGlow = $window.FindName('StatusDotGlow') +# 多 session 列表(画在 pill 自己的窗口里) +$script:sessionRowsPanel = $window.FindName('SessionPanel') +$script:sessionRows = $window.FindName('SessionRows') +$script:sessionListRow = $window.FindName('SessionListRow') + +# 玻璃体四周的留白:给投影留地方。 +# +# 留白是窗口的一部分(透明的),所以药丸的屏幕位置 = 窗口位置 + 留白, +# 而窗口位置 = 配置里的位置 - 留白。位置语义不变。 +# +# 面板的下边距比药丸大:面板比药丸厚,投影也该更重更远。 +try { + $pillBorder = $window.FindName('Pill') + if ($pillBorder) { $pillBorder.Margin = New-Object System.Windows.Thickness($script:glassMargin) } + $panelBorder = $window.FindName('SessionPanel') + if ($panelBorder) { $panelBorder.Margin = New-Object System.Windows.Thickness($script:glassMargin, 6, $script:glassMargin, $script:glassMarginPanelBottom) } +} catch { + Dbg "GLASS MARGIN FAIL: $($_.Exception.Message)" +} + +# 磨砂颗粒层。 +# +# 为什么需要它:半透明的深色面板,平涂的话读起来就是"一块染了色的矩形"; +# 磨砂之所以是磨砂,靠的是表面那层把光打散的细颗粒。没有颗粒,再准的 +# 渐变和高光也只到"半透明",到不了"磨砂"。 +# +# 用 DrawingBrush 画一堆亚像素小白点平铺,而不是像素级噪点: +# - DrawingBrush 在 WPF 里是矢量,缩放不失真,也不会因为 DPI 变化 +# 出现摩尔纹; +# - 粒子数量少(几百个点铺一格),合成开销可以忽略; +# - 白点 + 极低不透明度 = 在深色玻璃上像是表面的一层细霜。 +function New-GrainBrush { + # 颗粒点是什么颜色随极走:深色玻璃上是白点(像一层细霜),浅色玻璃上 + # 得反过来用黑点 —— 白点在白玻璃上等于没有。 + param([int]$Tile = 64, [int]$Seed = 7, [int]$Dots = 260, [string]$Color = '#FFFFFFFF') + try { + $rnd = [Random]::new($Seed) + $geo = New-Object System.Windows.Media.GeometryGroup + for ($i = 0; $i -lt $Dots; $i++) { + $x = $rnd.NextDouble() * $Tile + $y = $rnd.NextDouble() * $Tile + $w = 0.5 + $rnd.NextDouble() * 1.1 + $rect = New-Object System.Windows.Rect $x, $y, $w, $w + $geo.Children.Add((New-Object System.Windows.Media.RectangleGeometry $rect)) | Out-Null + } + $drawing = New-Object System.Windows.Media.GeometryDrawing + $drawing.Geometry = $geo + $drawing.Brush = (New-Object System.Windows.Media.SolidColorBrush ([System.Windows.Media.ColorConverter]::ConvertFromString($Color))) + $brush = New-Object System.Windows.Media.DrawingBrush + $brush.Drawing = $drawing + $brush.TileMode = [System.Windows.Media.TileMode]::Tile + $brush.ViewportUnits = [System.Windows.Media.BrushMappingMode]::Absolute + $brush.Viewport = New-Object System.Windows.Rect 0, 0, $Tile, $Tile + $brush.Stretch = [System.Windows.Media.Stretch]::None + return $brush + } catch { + Dbg "GRAIN FAIL: $($_.Exception.Message)" + return $null + } +} + +# 把颗粒铺到两个玻璃体上。 +# +# 两套笔刷:深色极用白点,浅色极用黑点。启动时先铺深色那套,换极时由 +# Update-GlassInk 换过去。 +$script:grainBrushDark = $null +$script:grainBrushLight = $null +try { + $script:grainBrushDark = New-GrainBrush -Color '#FFFFFFFF' + $script:grainBrushLight = New-GrainBrush -Color '#FF000000' + $grain = $script:grainBrushDark + if ($grain) { + foreach ($name in @('PillGrain', 'PanelGrain')) { + $target = $window.FindName($name) + if ($target) { $target.Fill = $grain; $target.Opacity = 0.015 } + } + Dbg 'GRAIN: applied' + } +} catch { + Dbg "GRAIN APPLY FAIL: $($_.Exception.Message)" +} + +# --------------------------------------------------------------------------- +# Liquid Glass 材质引擎 +# --------------------------------------------------------------------------- +# +# 规范来源(三份一手材料): +# - Apple HIG "Materials"(Liquid Glass / regular 变体,2025-09 修订) +# - Apple HIG "Color"(Liquid Glass color,2025-12 修订) +# - WWDC25 "Meet Liquid Glass"(session 219) +# +# 落到这个插件上是四件事: +# +# 1. **材质没有固有颜色**,颜色来自它背后的内容。所以底色不是画上去的 +# 深蓝灰,而是"背后那块屏幕抓下来模糊掉"的结果。之前那种固定色 +# 染上去,读起来是"一块染了色的矩形",不是玻璃。 +# 2. **regular 变体 = 模糊 + 按需调整明度**,目的是保证前景文字始终 +# 可读。背后亮就多压一点暗,背后暗就抬一点亮 —— 压多少是**算出来 +# 的**,不是写死的。 +# 3. **高光随几何走**(specular)。玻璃边缘被光折出的那道亮线,是 +# "贴了张图"和"真玻璃"的分界。 +# 4. **尺寸越大材质越厚**。面板比药丸大得多,就该更不透明、阴影更深 +# (HIG 原文:larger elements appear more opaque)。 +# +# 为什么自己抓屏:Windows 的系统磨砂在这个窗口上都实测无效 —— +# - layered 窗口(AllowsTransparency=True,这个窗口就是)拿不到 +# DWM backdrop:SetWindowCompositionAttribute(ACCENT_ENABLE_BLURBEHIND) +# 返回成功但背景照样清晰可见; +# - 非 layered 窗口能拿到 DWMWA_SYSTEMBACKDROP_TYPE,但 WPF 自己画的 +# 背景会把它盖成整块纯黑。 +# 三窗真机对照见 dwm-probe 的截图。所以只剩一条路:自己抓屏自己模糊。 +# +# 两个细节决定成败: +# 1. **抓屏时把自己排除掉**(SetWindowDisplayAffinity +# WDA_EXCLUDEFROMCAPTURE)。不排除的话抓到的是自己的上一帧,模糊 +# 之后是一团脏影子,而不是身后的桌面。实测:排除前采样点全是窗口 +# 自己的颜色,排除后立刻变成背后的桌面色。 +# 2. **坐标一律用物理像素**。PointToScreen 给的是设备像素;WPF 的 +# Left/Top 是 DIP,缩放不是 100% 时两者不等,混用会错位。 +# +# 模糊是"降采样 1/4 + BlurEffect + 放大":磨砂要的是"柔而不糊",而且 +# 比全尺寸模糊快一个数量级。 +$script:glassBackdrop = $null +$script:glassLuminance = 0.0 +$script:glassLastUpdate = [DateTime]::MinValue +$script:glassDragStamp = [DateTime]::MinValue + +# 材质几何参数。 +# +# 药丸用正圆角(r = 高/2,就是个胶囊 —— 苹果灵动岛本身也是胶囊形)。 +# 面板用"连续圆角"(Smooth=1,超椭圆 n=5),这是苹果那套质感在 +# 大面积圆角上最显眼的一笔:正圆弧的角一眼就是"塑料片"。 +$script:glassRadiusPill = 35.0 +$script:glassRadiusPanel = 18.0 +$script:glassSmoothPanel = 1.0 + +# 背景多久重抓一次。玻璃要跟着背后的内容走(WWDC25:内容滚过下面时 +# 材质会跟着变),不然就成了一张拍好的静态贴图。 +$script:glassRefreshMs = 2500 + +function Get-WindowDeviceScale { + # 100 DIP 在屏幕上到底是多少物理像素。 + try { + $p0 = $window.PointToScreen([System.Windows.Point]::new(0, 0)) + $p1 = $window.PointToScreen([System.Windows.Point]::new(100, 0)) + $sx = [Math]::Abs($p1.X - $p0.X) / 100.0 + $sy = [Math]::Abs($p1.Y - $p0.Y) / 100.0 + if ($sx -lt 0.1 -or $sx -gt 10) { $sx = 1.0 } + if ($sy -lt 0.1 -or $sy -gt 10) { $sy = 1.0 } + return @($sx, $sy) + } catch { + return @(1.0, 1.0) + } +} + +function New-RoundedRectGeometry { + # 圆角矩形路径。 + # + # Smooth 是"连续圆角"开关:0 = 正圆弧,1 = 苹果那种连续圆角。 + # 连续圆角用 n=5 的超椭圆逼近(iOS squircle 的标准近似),按 24 段 + # 折线采样,1x 下与真曲线肉眼无差。 + # + # Inset 把整条路径往里挪:画内缩的高光线和厚度暗线时要用。 + param([double]$W, [double]$H, [double]$R, [double]$Smooth = 0.0, [double]$Inset = 0.0, [int]$Steps = 24) + try { + $w2 = $W - 2 * $Inset + $h2 = $H - 2 * $Inset + if ($w2 -le 2 -or $h2 -le 2) { return $null } + $r = [Math]::Min($R - $Inset, [Math]::Min($w2, $h2) / 2.0) + if ($r -le 0.5) { + $rect = New-Object System.Windows.Rect $Inset, $Inset, $w2, $h2 + $g = New-Object System.Windows.Media.RectangleGeometry $rect + $g.Freeze() + return $g + } + # e = 1 是正圆弧;e = 2/n,超椭圆 n=5 时 e = 0.4。 + $e = 1.0 + if ($Smooth -gt 0.01) { + $n = 2.0 + 3.0 * [Math]::Min(1.0, $Smooth) + $e = 2.0 / $n + } + $half = [Math]::PI / 2.0 + $pts = New-Object 'System.Collections.Generic.List[System.Windows.Point]' + # 右上角 + for ($i = 0; $i -le $Steps; $i++) { + $t = $half * $i / $Steps + $s = [Math]::Pow([Math]::Sin($t), $e) + $c = [Math]::Pow([Math]::Cos($t), $e) + $pts.Add((New-Object System.Windows.Point ($Inset + $w2 - $r + $r * $s), ($Inset + $r - $r * $c))) + } + # 右下角 + for ($i = 0; $i -le $Steps; $i++) { + $t = $half * $i / $Steps + $s = [Math]::Pow([Math]::Sin($t), $e) + $c = [Math]::Pow([Math]::Cos($t), $e) + $pts.Add((New-Object System.Windows.Point ($Inset + $w2 - $r + $r * $c), ($Inset + $h2 - $r + $r * $s))) + } + # 左下角 + for ($i = 0; $i -le $Steps; $i++) { + $t = $half * $i / $Steps + $s = [Math]::Pow([Math]::Sin($t), $e) + $c = [Math]::Pow([Math]::Cos($t), $e) + $pts.Add((New-Object System.Windows.Point ($Inset + $r - $r * $s), ($Inset + $h2 - $r + $r * $c))) + } + # 左上角(最后一点回到起点,由 IsClosed 收口) + for ($i = 0; $i -lt $Steps; $i++) { + $t = $half * $i / $Steps + $s = [Math]::Pow([Math]::Sin($t), $e) + $c = [Math]::Pow([Math]::Cos($t), $e) + $pts.Add((New-Object System.Windows.Point ($Inset + $r - $r * $c), ($Inset + $r - $r * $s))) + } + $fig = New-Object System.Windows.Media.PathFigure + $fig.StartPoint = $pts[0] + $fig.IsClosed = $true + for ($i = 1; $i -lt $pts.Count; $i++) { + $seg = New-Object System.Windows.Media.LineSegment $pts[$i], $pts[$i] + $fig.Segments.Add($seg) | Out-Null + } + $geo = New-Object System.Windows.Media.PathGeometry + $geo.Figures.Add($fig) | Out-Null + $geo.Freeze() + return $geo + } catch { + Dbg "ROUNDRECT FAIL: $($_.Exception.Message)" + return $null + } +} + +function New-GlassStop { + # 一个带透明度的渐变色标。 + param([System.Windows.Media.Color]$Color, [double]$Offset, [double]$Alpha) + $a = [int][Math]::Max(0, [Math]::Min(255, [Math]::Round($Alpha * 255))) + $s = New-Object System.Windows.Media.GradientStop + $s.Color = [System.Windows.Media.Color]::FromArgb($a, $Color.R, $Color.G, $Color.B) + $s.Offset = $Offset + return $s +} + +function Measure-Luminance { + # 一块图的整体亮度(0..1)。玻璃压多暗、抬多亮,全看它。 + param($Bitmap) + try { + if (-not $Bitmap) { return 0.0 } + $w = $Bitmap.PixelWidth + $h = $Bitmap.PixelHeight + if ($w -le 0 -or $h -le 0) { return 0.0 } + $stride = $w * 4 + $buf = New-Object byte[] ($stride * $h) + $Bitmap.CopyPixels($buf, $stride, 0) + # 每 4 个像素取 1 个,亮度用 Rec.709 的系数。 + $sum = 0.0 + $n = 0 + for ($i = 0; $i -lt $buf.Length; $i += 16) { + $b = $buf[$i]; $g = $buf[$i + 1]; $r = $buf[$i + 2] + $sum += (0.2126 * $r + 0.7152 * $g + 0.0722 * $b) + $n++ + } + if ($n -eq 0) { return 0.0 } + $v = $sum / $n / 255.0 + if ($v -lt 0.0) { $v = 0.0 } + if ($v -gt 1.0) { $v = 1.0 } + return [double]$v + } catch { + return 0.0 + } +} + +function New-GlassCapture { + # 抓某个元素底下那块屏幕,模糊掉。 + # 返回 @{ Brush = ImageBrush; Luminance = 0..1 },失败返回 $null。 + # + # 抓屏本身不在这里做排除:药丸和面板在同一个窗口里,排除一次、 + # 连着抓两块,比抓两次省一半时间,也少一次窗口闪动。 + param($Element) + try { + if (-not $Element) { return $null } + $w = $Element.ActualWidth + $h = $Element.ActualHeight + if ($w -lt 8 -or $h -lt 8) { return $null } + $scale = Get-WindowDeviceScale + $sx = [double]$scale[0]; $sy = [double]$scale[1] + # 元素左上角的物理像素位置。 + $origin = $Element.PointToScreen([System.Windows.Point]::new(0, 0)) + $devW = [int][Math]::Ceiling($w * $sx) + $devH = [int][Math]::Ceiling($h * $sy) + if ($devW -lt 8 -or $devH -lt 8) { return $null } + if ($devW -gt 4000 -or $devH -gt 4000) { return $null } + + $bmp = $null + $src = $null + $ms = $null + try { + $bmp = New-Object System.Drawing.Bitmap $devW, $devH + $g = [System.Drawing.Graphics]::FromImage($bmp) + # CopyFromScreen 走的是屏幕 DC,坐标是物理像素。 + $g.CopyFromScreen([int]$origin.X, [int]$origin.Y, 0, 0, (New-Object System.Drawing.Size $devW, $devH)) + $g.Dispose() + # System.Drawing.Bitmap -> WPF BitmapSource。 + # + # 走 MemoryStream 而不是 Imaging.CreateBitmapSourceFromHBitmap: + # 后者所在的 System.Windows.Media.Imaging.Imaging 在 .NET Framework + # 里是 internal,PowerShell 直接 [System.Windows.Media.Imaging.Imaging] + # 报"找不到类型"(.NET Core 才把它公开)。PNG 是无损的,像素一模一样。 + $ms = New-Object System.IO.MemoryStream + $bmp.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png) + $ms.Position = 0 + $src = New-Object System.Windows.Media.Imaging.BitmapImage + $src.BeginInit() + $src.CacheOption = [System.Windows.Media.Imaging.BitmapCacheOption]::OnLoad + $src.StreamSource = $ms + $src.EndInit() + $src.Freeze() + } finally { + if ($ms) { $ms.Dispose() } + if ($bmp) { $bmp.Dispose() } + } + + # 降采样 -> 模糊 -> 放大回来。 + # + # 模糊半径按**物理像素**定:24px 的真实模糊,在 1/4 尺度上就是 6。 + # 直接写 9 的话,在 70px 高的药丸上(缩小后只有 17px)那 9px 几乎 + # 把整张图抹平了 —— 那不是磨砂,是一块纯色板。 + $scaleDown = 4 + $smallW = [int][Math]::Max(4, [int]($devW / $scaleDown)) + $smallH = [int][Math]::Max(4, [int]($devH / $scaleDown)) -# 状态配色 + $img = New-Object System.Windows.Controls.Image + $img.Source = $src + $img.Width = $smallW + $img.Height = $smallH + $img.Stretch = [System.Windows.Media.Stretch]::Fill + $blur = New-Object System.Windows.Media.Effects.BlurEffect + $blur.Radius = 6 + $img.Effect = $blur + # 必须先 Measure + Arrange 再 Render。 + # + # RenderTargetBitmap.Render() 只画"已经摆好"的视觉对象,不会替它做 + # 布局。离屏的 Image 从没被排过版,Render 出来就是一张全黑 —— 实测 + # 同一张图:不排版亮度 0.0,排版后 0.35。 + $img.Measure((New-Object System.Windows.Size($smallW, $smallH))) + $img.Arrange((New-Object System.Windows.Rect(0, 0, $smallW, $smallH))) + + $rtb = New-Object System.Windows.Media.Imaging.RenderTargetBitmap $smallW, $smallH, 96, 96, ([System.Windows.Media.PixelFormats]::Pbgra32) + $rtb.Render($img) + + $brush = New-Object System.Windows.Media.ImageBrush $rtb + $brush.Stretch = [System.Windows.Media.Stretch]::Fill + $brush.TileMode = [System.Windows.Media.TileMode]::None + return @{ Brush = $brush; Luminance = (Measure-Luminance $rtb) } + } catch { + Dbg "GLASS CAPTURE FAIL: $($_.Exception.Message)" + return $null + } +} + +function Update-GlassGeometry { + # 按元素当前实际尺寸重建材质几何。 + # 尺寸会变(药丸高度可配、列表行数会变),所以每次都要重算。 + param([string]$Prefix, [double]$Radius, [double]$Smooth) + try { + $glass = $window.FindName("${Prefix}Glass") + if (-not $glass) { return $false } + $w = $glass.ActualWidth + $h = $glass.ActualHeight + if ($w -lt 8 -or $h -lt 8) { return $false } + $geo = New-RoundedRectGeometry $w $h $Radius $Smooth 0 + $geoRim = New-RoundedRectGeometry $w $h $Radius $Smooth 1.25 + $geoEdge = New-RoundedRectGeometry $w $h $Radius $Smooth 3.5 + if (-not $geo) { return $false } + # Clip 把整个玻璃体裁成同一个形状 —— Border 的 CornerRadius 不裁子元素, + # 光靠它画出来是方的。 + $glass.Clip = $geo + $tint = $window.FindName("${Prefix}Tint"); if ($tint) { $tint.Data = $geo } + $grain = $window.FindName("${Prefix}Grain"); if ($grain) { $grain.Data = $geo } + $rim = $window.FindName("${Prefix}Rim"); if ($rim) { $rim.Data = $geoRim; $rim.StrokeThickness = 1.5 } + $edge = $window.FindName("${Prefix}Edge"); if ($edge) { $edge.Data = $geoEdge; $edge.StrokeThickness = 1.5 } + # 材质层住在 Canvas 里,位置由 Canvas 管。Canvas 不测子元素,所以这 + # 几条 Path 只画不撑;但坐标还是得落在 (0,0),否则 Path 的 Data 矩形 + # 会从原点偏移出去。 + foreach ($layer in @($tint, $grain, $rim, $edge)) { + if (-not $layer) { continue } + [System.Windows.Controls.Canvas]::SetLeft($layer, 0) + [System.Windows.Controls.Canvas]::SetTop($layer, 0) + } + return $true + } catch { + Dbg "GLASS GEOMETRY FAIL: $($_.Exception.Message)" + return $false + } +} + +# --------------------------------------------------------------------------- +# 材质的两极:浅色玻璃 / 深色玻璃 +# --------------------------------------------------------------------------- +# +# HIG "Materials" 说 Liquid Glass 的 regular 变体是"模糊 + **按需调整背景明度** +# 以保证文字可读"(adjusts the luminosity of background content to maintain +# legibility of text)。这一条在本插件上不是装饰性的措辞,是硬指标: +# +# 背后是一张白纸时,深色玻璃 + 白字会糊成一片。实测(真机截图): +# 次级文字(60% 白)的对比度掉到 2.7:1,"3h25m 99%"(30% 白)掉到 1.7:1, +# 后者基本看不见。深色玻璃在亮背景上"看起来像玻璃",但字读不出来 —— +# 而读不出来就没有质感可言。 +# +# 苹果自己的解法不是"把深色玻璃压得更黑",是让材质**换极**:macOS 的 Dock、 +# 菜单、控制中心在浅色桌面上就是浅色玻璃配深色内容(浅色模式下系统也把 +# 前景翻成深色)。所以这里保留两极: +# +# dark 极:深色玻璃 + 白色内容。**默认**,统一的黑玻璃。 +# light 极:浅色玻璃 + 深色内容。钉死时才用。 +# +# 但**默认是 dark**:浅色极在白底上被嫌"灰突突"。于是材质改成**收敛到目标 +# 灰度** —— 不管背后是白纸还是黑终端,玻璃都停在同一个灰度上(深色极 +# 0.115 ≈ #1D1D1D,就是 macOS 那个 Dynamic Island 的黑)。底下是什么,岛 +# 都是同一块黑,这才叫"统一"。 +# +# 两极之间有**迟滞**(只在 auto 模式下用得上):亮过 0.60 才切浅色,暗回 +# 0.58 才切深色。没有迟滞,背景亮度在阈值附近抖一下,药丸就跟着闪一下。 +# +# config.json 里的 materialPole:dark(默认)/ light / auto。 +# +# 前景色是**跟着极走的**:深色玻璃上的白字搬到浅色玻璃上就是"白底白字", +# 所以换极的时候要重刷一遍(Update-GlassInk)。 +# +# 药丸和展开面板是**两块独立的材质**,背后可能差得很远(药丸压在一段亮色 +# 标题栏上,面板底下却是一片深色编辑器)。所以极性是**每块面各判各的**: +# $script:glassPole 给药丸,$script:glassPanelPole 给面板;墨色也一样 —— +# 药丸的字用药丸的墨,列表行的字用面板的墨。 +# +# 这不是洁癖,是修一个真实缺陷:早先只有一份全局极性,面板判出浅色极就把 +# 全局翻了,于是药丸变成"暗玻璃 + 深色字",白字没了、深色字压在深色玻璃上 +# 一样看不见 —— 看起来就像渲染坏了。HIG "Materials" 写的也是每一块材质 +# 各自调整明度来保住可读性。 +$script:glassPole = 'dark' +$script:glassPanelPole = 'dark' + +# 材质参数。 +# +# 提出来命名,不是为了好看:这些数字是**对比度的保证**,藏在一堆表达式 +# 里的数字没人能核对。smoke.mjs 直接读这一段,然后按最坏背景重算前景/ +# 背景的对比度 —— 改这里的数字,对应的断言就会打红。 +# +# 玻璃的**目标灰度**(0..1,按 sRGB 字节算,不是 WCAG 相对亮度)。 +# +# HIG "Materials" 原话:regular 变体 "blurs and **adjusts the luminosity** of +# background content to maintain legibility of text"。所以算法是"先定目标 +# 灰度,再反推要混多少黑(背后更亮)或多少白(背后更暗)",而不是"压固定 +# 比例的黑"。 +# +# 混完之后玻璃落在目标灰度上,**跟背后是什么无关** —— 白纸和黑终端底下都 +# 是同一块黑。这就是"统一黑色":macOS 那个 Dynamic Island 就是这么做的, +# 它不跟着背景变。 +# +# 早先那套是"压固定比例的黑",背后全白时玻璃停在中灰(字节 106 ≈ #6A6A6A), +# 深色字再压上去就成了脏灰 —— 用户嫌它"灰突突"。改成收敛到目标灰度之后, +# 白底上就是真正的黑玻璃。 +# +# 深色极取 0.115(字节 29 ≈ #1D1D1D,接近 macOS 的 #1C1C1E),白字压上去 +# 有 16.9:1。浅色极取 0.90(字节 230 ≈ #E6E6E6),深色字压上去有 12.7:1。 +$script:glassTargetByteDark = 0.115 +$script:glassTargetByteLight = 0.90 +# 大元素 = 更厚的材质(HIG: larger elements appear more opaque):厚就是更 +# 靠近材料自己的颜色。深色极往黑走,浅色极往白走。 +$script:glassThickDark = 0.25 +$script:glassThickLight = 0.25 +# 两极之间有**迟滞**(只在 auto 模式下用得上):亮过 0.60 才切浅色,暗回 +# 0.58 才切深色。没有迟滞,背景亮度在阈值附近抖一下,药丸就跟着闪一下。 +$script:glassPoleLightOn = 0.60 +$script:glassPoleLightOff = 0.58 + +# 前景色表。HIG "Color" 的系统色:暗色极用暗色模式那套,浅色极用 macOS +# 浅色模式那套。 +# +# 浅色极用的是 **黑色透明**(macOS 的 label / secondaryLabel / tertiaryLabel +# 都是黑色加不同透明度),不是 iOS 那种灰加透明度:iOS 的那套是给"底就是 +# 纯白"设计的,而我们的磨砂玻璃永远不是纯白 —— 拿 iOS 的灰透明去压一层 +# 灰玻璃,次级文字的对比度会直接掉到 3 以下。 +$script:glassInkDark = @{ + Primary = '#FFFFFFFF' # label + Secondary = '#99EBEBF5' # secondaryLabel 60% + Tertiary = '#4DEBEBF5' # tertiaryLabel 30% + Muted = '#FF8B93A1' + Dim = '#FF8E8E93' # systemGray + Warn = '#FFFF9F0A' # systemOrange + Danger = '#FFFF453A' # systemRed + Track = '#26FFFFFF' + Accent = '#FF0A84FF' # systemBlue (dark) + Hover = '#FFFF453A' +} + +$script:glassInkLight = @{ + Primary = '#D9000000' # macOS label: 85% 黑 + Secondary = '#8C000000' # secondaryLabel: 55% 黑 + Tertiary = '#4D000000' # tertiaryLabel: 30% 黑 + Muted = '#FF6C6C70' + Dim = '#FF6C6C70' + Warn = '#FFC93400' # systemOrange (light) + Danger = '#FFD70015' # systemRed (light) + Track = '#1A000000' + Accent = '#FF007AFF' # systemBlue (light) + Hover = '#FFFF3B30' # systemRed (light) +} + +# 浅色极的系统色。HIG "Color" 系统色表 light 一列。 +# +# 注意这些不是把 dark 列调亮就行:浅色背景上要的是**深色**版本(label 色), +# 否则一个黄点在白纸上等于没有。 +$stateMapLight = @{ + idle = @{ dot='#FF6C6C70'; ring='#FF6C6C70'; label='mcode'; icon='' } + thinking = @{ dot='#FFA88700'; ring='#FFA88700'; label='mcode · 思考中'; icon='' } + working = @{ dot='#FF007AFF'; ring='#FF007AFF'; label='mcode · 执行中'; icon='⚙' } + waiting = @{ dot='#FFC93400'; ring='#FFC93400'; label='mcode · 等待审批'; icon='?' } + done = @{ dot='#FF248A3D'; ring='#FF248A3D'; label='mcode · 已完成'; icon='✓' } + error = @{ dot='#FFD70015'; ring='#FFD70015'; label='mcode · 出错'; icon='✕' } +} + +$familyMapLight = @{ + shell = '#FF248A3D' # bash / shell → 绿 + read = '#FF007AFF' # read → 蓝 + write = '#FFA88700' # edit / write → 琥珀 + search = '#FF8944AB' # grep / glob → 紫 + task = '#FF007E8A' # task → 青 + web = '#FFD6246D' # web_search/fetch → 粉 + plan = '#FF8E8E93' # todowrite → 灰 +} + +# 药丸这一面的墨色(文字 / 强调色)。 +function Get-GlassInk { + if ($script:glassPole -eq 'light') { return $script:glassInkLight } + return $script:glassInkDark +} + +# 面板这一面的墨色。两块面背后亮度不同,墨色就得分开。 +function Get-PanelInk { + if ($script:glassPanelPole -eq 'light') { return $script:glassInkLight } + return $script:glassInkDark +} + +# 当前极的状态色表(点 / 环)。 +function Get-GlassStateMap { + if ($script:glassPole -eq 'light') { return $stateMapLight } + return $stateMap +} + +# 面板的状态色表:列表行画在面板上,跟着面板的极走。 +function Get-PanelStateMap { + if ($script:glassPanelPole -eq 'light') { return $stateMapLight } + return $stateMap +} + +# 当前极的家族色表。 +function Get-GlassFamilyMap { + if ($script:glassPole -eq 'light') { return $familyMapLight } + return $familyMap +} + +function Resolve-GlassPole { + # 该用哪一极。 + # + # auto 按背后亮度切,带迟滞:亮过 0.60 才切浅色,暗回 0.58 才切深色。 + # 没有迟滞的话,背景亮度在阈值附近抖一下,药丸就跟着闪一下。 + # + # 阈值不是随手取的,要跟**文字对比度**对齐: + # - 浅色极:深色文字压在玻璃上,要够 4.5:1,玻璃的相对亮度得在 0.227 + # 以上 —— 按浅色极的染色公式反推,背后亮度得高于 0.58 才够。 + # - 深色极:白字压在收敛后的玻璃上。玻璃固定落在目标灰度 0.115 上, + # 白字有 16.9:1,怎么切都不会读不清。 + # 所以 0.60 / 0.58 这对阈值是**算出来的**,不是调出来的。 + # + # $Current 是**这块面自己**现在的极(药丸和面板各算各的),迟滞相对它 + # 判断。这个函数不改任何全局状态:调用方拿到结果自己落。 + # + # 配置里钉死(materialPole = dark / light)时不切:调试浅色极需要它, + # "我就想要固定外观"的用户也需要它。注意钉死浅色极、又把它压在深色 + # 背景上,内容会看不清 —— 那是自己选的,不是 bug。 + param([double]$L, [string]$Current) + $forced = '' + try { $forced = [string]$cfg.materialPole } catch { $forced = '' } + if ($forced -eq 'dark' -or $forced -eq 'light') { return $forced } + if ($Current -eq 'light') { + if ($L -lt $script:glassPoleLightOff) { return 'dark' } + } else { + if ($L -gt $script:glassPoleLightOn) { return 'light' } + } + return $Current +} + +function Update-GlassGrain { + # 颗粒笔刷也跟极走:深色玻璃上白点,浅色玻璃上黑点。 + # 每块面用自己那一极的笔刷 —— 药丸和面板可以是不同的极。 + param([string]$Name, [string]$Pole) + try { + $gb = $script:grainBrushDark + if ($Pole -eq 'light') { $gb = $script:grainBrushLight } + if (-not $gb) { return } + $target = $window.FindName($Name) + if ($target) { $target.Fill = $gb } + } catch { + Dbg "GLASS GRAIN INK FAIL: $($_.Exception.Message)" + } +} + +function Update-GlassInk { + # 把**药丸这一面**的前景色刷到药丸的文本控件上。 + # + # 换极时必须重刷:深色玻璃上的白字搬到浅色玻璃上就是"白底白字"。 + # + # 面板上的字不归这里管 —— 列表行的墨色跟面板自己的极走,见 + # Update-SessionRowsInk。 + $ink = Get-GlassInk + try { + if ($script:stateText) { $script:stateText.Foreground = C $ink.Primary } + if ($script:messageText) { $script:messageText.Foreground = C $ink.Secondary } + if ($script:actionIcon) { $script:actionIcon.Foreground = C $ink.Secondary } + if ($script:elapsedText) { $script:elapsedText.Foreground = C $ink.Tertiary } + if ($script:usage5hText) { $script:usage5hText.Foreground = C $ink.Tertiary } + if ($script:progressTrack) { $script:progressTrack.Background = C $ink.Track } + if ($script:progressFill) { $script:progressFill.Background = C $ink.Accent } + if ($script:progressShimmer) { $script:progressShimmer.Fill = C $ink.Accent } + } catch { + Dbg "GLASS INK FAIL: $($_.Exception.Message)" + } + Update-GlassGrain 'PillGrain' $script:glassPole +} + +function Update-GlassPanelGrain { + # 面板的颗粒笔刷,跟面板自己的极走。 + Update-GlassGrain 'PanelGrain' $script:glassPanelPole +} + +function Update-SessionRowsInk { + # 换极后重画列表行 —— 行里的颜色是建行那一刻定下的,不会自己跟着变。 + # + # 只在列表本来就是展开的时候重画:没展开就别去动它(重画会把 pending + # 消解逻辑再跑一遍,没必要)。 + try { + if (-not $script:sessionRowsPanel) { return } + if ($script:sessionRowsPanel.Visibility -ne [System.Windows.Visibility]::Visible) { return } + Show-SessionList | Out-Null + } catch { + Dbg "GLASS ROWS REINK FAIL: $($_.Exception.Message)" + } +} + +function Apply-GlassMaterial { + # 按背后内容的亮度,决定玻璃怎么染、边缘怎么亮、阴影多重。 + # 这是 HIG 那句 "the amount of tint shifts to keep buttons legible" 落地处。 + param([string]$Prefix, [double]$Luminance, [bool]$Thick) + try { + $L = [Math]::Max(0.0, [Math]::Min(1.0, $Luminance)) + + # 先定极。换极了就把前景色重刷一遍(深色玻璃上的白字搬到浅色玻璃 + # 上就是白底白字),展开着的列表也要重画 —— 行里的颜色是建行时定的。 + # + # 每块面各判各的:药丸看药丸背后,面板看面板背后。两块面背后亮度可以 + # 差很远,共用一个极就会出现"暗玻璃 + 深色字"那种看着像坏掉的状态。 + $isPanel = ($Prefix -eq 'Panel') + $curPole = $script:glassPole + if ($isPanel) { $curPole = $script:glassPanelPole } + $pole = Resolve-GlassPole $L $curPole + if ($isPanel) { + if ($pole -ne $script:glassPanelPole) { + $script:glassPanelPole = $pole + Update-GlassPanelGrain + # 列表行的颜色是建行那一刻定下的,换极之后要重画。但重画会连带再 + # 抓一次背景(Show-SessionList 末尾会调 Update-GlassBackdrop),不能 + # 套在抓屏里做 —— 内层抓完就把"排除录屏"撤了,外层接着抓就抓到自 + # 己。这里只记一个标记,由 Update-GlassBackdrop 在抓完之后统一处理。 + $script:glassRowsNeedReink = $true + } + } else { + if ($pole -ne $script:glassPole) { + $script:glassPole = $pole + Update-GlassInk + } + } + + $dark = [System.Windows.Media.Color]::FromArgb(255, 0, 0, 0) + $white = [System.Windows.Media.Color]::FromArgb(255, 255, 255, 255) + + # =================== 目标灰度 =================== + # + # 两极共用同一套算法,差的只是这个目标值。 + # + # HIG "Materials" 原话:regular 变体 "blurs and **adjusts the luminosity** + # of background content to maintain legibility of text" —— 不是"盖一块半 + # 透明板",是把背景的明度调到文字读得清的那个值。 + # + # 所以这里不按"压多少黑"算,而是反过来:**先定一个目标灰度,再反推要混 + # 多少黑(背后比目标亮)或多少白(背后比目标暗)**: + # + # 背后比目标亮 -> 混黑:veil = 1 - target / L + # 背后比目标暗 -> 混白:lift = (target - L) / (1 - L) + # + # 混完之后玻璃落在目标灰度上,**跟背后是什么无关** —— 底下是白纸还是 + # 深色终端,岛都是同一块黑。这才是"统一黑色":macOS 那个 Dynamic Island + # 就是这么做的,它不跟着背景变。 + # + # 之前那套是"压固定比例的黑",背后全白时玻璃停在中灰(字节 106 ≈ #6A6A6A), + # 深色字压上去就成了脏灰 —— 用户嫌它"灰突突"。 + # 局部变量**不能**叫 $thick:函数参数是 [bool]$Thick,而 PowerShell 的 + # 变量名不区分大小写、参数的类型约束还在 —— $thick = 0.25 会被强制转成 + # $true,接着 (1.0 - $thick) 算出 0,目标灰度被乘成 0,玻璃成了纯黑 + # (veil 恒等于 1)。这个坑静态断言看不出来,只能靠"参数名和局部名不撞 + # 车"这条纪律,所以这里用 $thickMix。 + $target = $script:glassTargetByteDark + $thickMix = $script:glassThickDark + if ($pole -eq 'light') { + $target = $script:glassTargetByteLight + $thickMix = $script:glassThickLight + } + # 大元素 = 更厚的材质(HIG:larger elements appear more opaque)。 + # 厚就是更靠近材料自己的颜色:深色极更黑,浅色极更白。 + if ($Thick) { + if ($pole -eq 'light') { $target = $target + (1.0 - $target) * $thickMix } + else { $target = $target * (1.0 - $thickMix) } + } + $veil = 0.0 + $lift = 0.0 + if ($L -gt $target) { + $veil = 1.0 - ($target / $L) + } else { + $lift = ($target - $L) / (1.0 - $L) + } + + # =================== 染色层 =================== + $tintBrush = New-Object System.Windows.Media.LinearGradientBrush + $tintBrush.StartPoint = New-Object System.Windows.Point 0, 0 + $tintBrush.EndPoint = New-Object System.Windows.Point 0, 1 + if ($veil -gt 0.0) { + # 背后比目标亮:往玻璃上混黑。 + # + # 整块玻璃都压到目标灰度附近,只有底边更沉一点 —— 厚度感来自这一道。 + # 顶边**不要**放白色色标:渐变会在白与黑之间插值出中灰,顶端于是浮起 + # 一条发白的带子,看着像玻璃在发光,而不是玻璃的边缘。亮边归 rim 上那 + # 条真高光线管(那是一条线,不是一片)。 + $tintBrush.GradientStops.Add((New-GlassStop $dark 0.00 ($veil * 0.98))) | Out-Null + $tintBrush.GradientStops.Add((New-GlassStop $dark 0.34 ($veil))) | Out-Null + $tintBrush.GradientStops.Add((New-GlassStop $dark 0.70 ($veil * 1.03))) | Out-Null + $tintBrush.GradientStops.Add((New-GlassStop $dark 1.00 ($veil * 1.06))) | Out-Null + } else { + # 背后比目标暗:往玻璃上混白。 + # 上面多混一点(受光),下面少混一点(厚度压深),玻璃才立得住。 + $tintBrush.GradientStops.Add((New-GlassStop $white 0.00 ($lift * 1.00))) | Out-Null + $tintBrush.GradientStops.Add((New-GlassStop $white 0.30 ($lift * 0.88))) | Out-Null + $tintBrush.GradientStops.Add((New-GlassStop $white 0.68 ($lift * 0.76))) | Out-Null + $tintBrush.GradientStops.Add((New-GlassStop $white 1.00 ($lift * 0.64))) | Out-Null + } + $tintPath = $window.FindName("${Prefix}Tint") + if ($tintPath) { $tintPath.Fill = $tintBrush } + + # =================== 边缘 =================== + # + # 暗玻璃上是白色高光(specular);浅玻璃上白高光是看不见的(底就是 + # 白的),真正让它立住的是一条**极细的暗边** —— 浮层在浅色背景上的 + # 分界靠它。 + $rimBrush = New-Object System.Windows.Media.RadialGradientBrush + $rimBrush.Center = New-Object System.Windows.Point 0.5, 0 + $rimBrush.RadiusX = 0.70 + $rimBrush.RadiusY = 1.30 + if ($pole -eq 'light') { + $rimBrush.GradientStops.Add((New-GlassStop $dark 0.00 0.22)) | Out-Null + $rimBrush.GradientStops.Add((New-GlassStop $dark 0.45 0.07)) | Out-Null + $rimBrush.GradientStops.Add((New-GlassStop $dark 0.80 0.02)) | Out-Null + $rimBrush.GradientStops.Add((New-GlassStop $dark 1.00 0.00)) | Out-Null + } else { + $rimBrush.GradientStops.Add((New-GlassStop $white 0.00 0.55)) | Out-Null + $rimBrush.GradientStops.Add((New-GlassStop $white 0.35 0.24)) | Out-Null + $rimBrush.GradientStops.Add((New-GlassStop $white 0.75 0.07)) | Out-Null + $rimBrush.GradientStops.Add((New-GlassStop $white 1.00 0.02)) | Out-Null + } + $rimPath = $window.FindName("${Prefix}Rim") + if ($rimPath) { $rimPath.Stroke = $rimBrush } + + # =================== 厚度暗边 =================== + # 玻璃下缘把光折射开了,内侧有一圈暗线。浅色玻璃上这条线要比深色极 + # 轻,否则发脏。 + $edgeBottom = 0.46 + if ($pole -eq 'light') { $edgeBottom = 0.30 } + $edgeBrush = New-Object System.Windows.Media.LinearGradientBrush + $edgeBrush.StartPoint = New-Object System.Windows.Point 0, 0 + $edgeBrush.EndPoint = New-Object System.Windows.Point 0, 1 + $edgeBrush.GradientStops.Add((New-GlassStop $dark 0.00 0.00)) | Out-Null + $edgeBrush.GradientStops.Add((New-GlassStop $dark 0.58 0.00)) | Out-Null + $edgeBrush.GradientStops.Add((New-GlassStop $dark 1.00 $edgeBottom)) | Out-Null + $edgePath = $window.FindName("${Prefix}Edge") + if ($edgePath) { $edgePath.Stroke = $edgeBrush } + + # =================== 投影 =================== + # 背后越亮越忙,投影越重(WWDC25:内容滚过下面时阴影会加重)。 + # 浅色玻璃没有深色玻璃那层"自带暗底"撑着,全靠投影立住,所以要更柔、 + # 更大。 + $shadow = $window.FindName("${Prefix}Shadow") + if ($shadow) { + if ($pole -eq 'light') { + if ($Thick) { $shadow.Opacity = [Math]::Min(0.46, 0.20 + 0.18 * $L); $shadow.BlurRadius = 44; $shadow.ShadowDepth = 12 } + else { $shadow.Opacity = [Math]::Min(0.38, 0.16 + 0.16 * $L); $shadow.BlurRadius = 32; $shadow.ShadowDepth = 8 } + } else { + if ($Thick) { $shadow.Opacity = [Math]::Min(0.72, 0.36 + 0.32 * $L) } + else { $shadow.Opacity = [Math]::Min(0.62, 0.30 + 0.28 * $L) } + } + } + # 亮度变化不大时不记日志:材质每 2.5s 刷一次,全记的话日志一天几万行。 + # 换极是稀有事件,单独记一行 —— 排查"为什么药丸变白了"就看这条。 + # 两块面各记各的:药丸和面板可能真的在不同极上。 + if ($script:glassLoggedPoles[$Prefix] -ne $pole) { + $script:glassLoggedPoles[$Prefix] = $pole + Dbg ("GLASS " + $Prefix.ToUpper() + " POLE -> " + $pole) + } + if ($null -eq $script:glassLoggedLum[$Prefix] -or [Math]::Abs($L - $script:glassLoggedLum[$Prefix]) -gt 0.12) { + $script:glassLoggedLum[$Prefix] = $L + Dbg ("GLASS " + $Prefix + " L=" + [Math]::Round($L, 3) + " veil=" + [Math]::Round($veil, 3) + " lift=" + [Math]::Round($lift, 3) + " pole=" + $pole) + } + return $true + } catch { + Dbg "GLASS MATERIAL FAIL: $($_.Exception.Message)" + return $false + } +} + +function Update-GlassBackdrop { + # 重新抓一次背景。展开时、窗口移动后、周期刷新时都要调。 + try { + $pillGlass = $window.FindName('PillGlass') + if (-not $pillGlass) { return $false } + $panelGlass = $window.FindName('PanelGlass') + + # 抓屏时把自己排除,否则抓到的是自己的上一帧。 + $hwnd = [IntPtr]::Zero + $excluded = $false + try { + $hwnd = [System.Windows.Interop.WindowInteropHelper]::new($window).Handle + if ($hwnd -and [WinAPI]::SetWindowDisplayAffinity($hwnd, 0x00000011)) { $excluded = $true } + } catch {} + + try { + $pillCap = New-GlassCapture $pillGlass + if ($pillCap) { + $pillGlass.Background = $pillCap.Brush + $script:glassBackdrop = $pillCap.Brush + $script:glassLuminance = $pillCap.Luminance + [void](Apply-GlassMaterial 'Pill' $pillCap.Luminance $false) + } + if ($panelGlass -and $panelGlass.Visibility -eq [System.Windows.Visibility]::Visible) { + $panelCap = New-GlassCapture $panelGlass + if ($panelCap) { + $panelGlass.Background = $panelCap.Brush + [void](Apply-GlassMaterial 'Panel' $panelCap.Luminance $true) + } + } + } finally { + if ($excluded -and $hwnd) { try { [void][WinAPI]::SetWindowDisplayAffinity($hwnd, 0) } catch {} } + } + $script:glassLastUpdate = [DateTime]::UtcNow + # 换极之后重画列表行。 + # + # 必须放在抓屏之外:重画会调 Show-SessionList,而 Show-SessionList 末尾 + # 又会调 Update-GlassBackdrop。套在抓屏里做,内层抓完就把"排除录屏" + # 撤掉了,外层接着抓就抓到自己的上一帧。 + if ($script:glassRowsNeedReink) { + $script:glassRowsNeedReink = $false + Update-SessionRowsInk + } + return $true + } catch { + Dbg "GLASS UPDATE FAIL: $($_.Exception.Message)" + return $false + } +} + +# 材质几何必须跟着元素的实际尺寸走。 +# +# 尺寸会变:药丸高度可配、列表行数会变、窗口宽度可配。尺寸一变, +# Clip 和那几条材质路径就跟真实形状对不上了(圆角错位、路径被裁掉 +# 一截)。所以每次都按当前 ActualWidth/ActualHeight 重算,尺寸没变 +# 就跳过 —— 每 400ms 一次的轮询里重建几何是浪费。 +$script:glassGeomSize = @{} +# 亮度日志按面分开记:药丸一块、面板一块。 +# +# 共用一个槽位是不行的 —— 亮度行只在变化超过 0.12 时才记,两块面轮流往 +# 同一个槽位里写,后写的那面几乎永远落不下自己的一行,于是"面板为什么变 +# 白了"这种事根本查不出(面板换了极,日志里却只有药丸的亮度)。 +$script:glassLoggedLum = @{} +$script:glassLoggedPoles = @{} +$script:glassRowsNeedReink = $false + +function Sync-GlassGeometry { + try { + $specs = @( + @{ P = 'Pill'; R = $script:glassRadiusPill; S = 0.0 }, + @{ P = 'Panel'; R = $script:glassRadiusPanel; S = $script:glassSmoothPanel } + ) + foreach ($sp in $specs) { + $glass = $window.FindName("$($sp.P)Glass") + if (-not $glass) { continue } + if ($sp.P -eq 'Panel' -and $glass.Visibility -ne [System.Windows.Visibility]::Visible) { continue } + $w = [int]$glass.ActualWidth + $h = [int]$glass.ActualHeight + if ($w -lt 8 -or $h -lt 8) { continue } + $key = "$($sp.P)" + if ($script:glassGeomSize[$key] -eq "$w x $h") { continue } + if (Update-GlassGeometry $sp.P $sp.R $sp.S) { + $script:glassGeomSize[$key] = "$w x $h" + } + } + return $true + } catch { + Dbg "GLASS GEOM SYNC FAIL: $($_.Exception.Message)" + return $false + } +} +$script:pillHeight = 70 +$script:listHideTimer = $null + +# 状态配色。 +# +# 用的是 Apple 暗色模式系统色(HIG "Color" 的系统色表,dark 一列), +# 不是 Tailwind 那套。系统色不是随便挑的:它们被调校过,在玻璃这种 +# 半透明表面上、在深浅两种背景下都能站得住,而且彼此之间是可分辨的。 $stateMap = @{ - idle = @{ dot='#FF6B7280'; ring='#FF6B7280'; label='mcode'; icon='' } - thinking = @{ dot='#FFEAB308'; ring='#FFEAB308'; label='mcode · 思考中'; icon='' } - working = @{ dot='#FF3B82F6'; ring='#FF3B82F6'; label='mcode · 执行中'; icon='⚙' } - waiting = @{ dot='#FFF59E0B'; ring='#FFF59E0B'; label='mcode · 等待审批'; icon='?' } - done = @{ dot='#FF22C55E'; ring='#FF22C55E'; label='mcode · 已完成'; icon='✓' } - error = @{ dot='#FFEF4444'; ring='#FFEF4444'; label='mcode · 出错'; icon='✕' } + idle = @{ dot='#FF8E8E93'; ring='#FF8E8E93'; label='mcode'; icon='' } + thinking = @{ dot='#FFFFD60A'; ring='#FFFFD60A'; label='mcode · 思考中'; icon='' } + working = @{ dot='#FF0A84FF'; ring='#FF0A84FF'; label='mcode · 执行中'; icon='⚙' } + waiting = @{ dot='#FFFF9F0A'; ring='#FFFF9F0A'; label='mcode · 等待审批'; icon='?' } + done = @{ dot='#FF30D158'; ring='#FF30D158'; label='mcode · 已完成'; icon='✓' } + error = @{ dot='#FFFF453A'; ring='#FFFF453A'; label='mcode · 出错'; icon='✕' } +} + +# --------------------------------------------------------------------------- +# 多 session 列表(v0.5.0) +# +# 鼠标停在灵动岛上时弹出一个列表:每个 session 一行,状态点 + 首条提问 + +# 运行中/待回复。点一行 = 消掉它(写 ack.signal,detector 收走)并把这个 +# session 切过去(mcode --session )。 +# +# 为什么是独立窗口而不是 WPF Popup: +# - Popup 是独立 HWND,不继承 owner 的 always-on-top。这个 pill 靠 +# Assert-Topmost 维持置顶,Popup 不在那套机制里,会被别的置顶窗口盖住。 +# - 独立窗口可以自己 Topmost=True / ShowActivated=False,接管完全在我们手里。 +# - pill 本体的 XAML 一个字都不用动 —— 它的视觉被反复调过,动它风险最大。 +# +# 代价:鼠标从 pill 移到列表窗口会离开 pill(两个 HWND),所以收起有一个 +# 宽限期,鼠标进列表窗口就取消。 +# --------------------------------------------------------------------------- +# --------------------------------------------------------------------------- +# 多 session 列表(v0.5.0) +# +# 鼠标停在灵动岛上时,在药丸下方展开一个列表:每个 session 一行,状态点 + +# 首条提问 + 运行中/待回复。点一行 = 消掉它(写 ack.signal,detector 收走) +# 并把这个 session 切过去(mcode --session )。 +# +# 为什么画在 pill 自己的窗口里,而不是另开一个窗口: +# 独立窗口方案实测过 —— 窗口能创建、位置正确、能被命中 +# (WindowFromPoint 返回它),却一个像素都不画:它自己矩形里 1053 个 +# 采样点只有 39 个是面板色,其余全是它背后的东西。隔离环境里同样的 +# 代码却能画出来,所以问题出在 widget 的上下文里。 +# 同一个窗口就没有这个问题:置顶、合成、命中测试全都走 pill 已经验证 +# 过的那一套。 +# +# 代价是窗口要变高。row 0 的高度锁死为药丸高度,所以展开时药丸本身一动 +# 不动,列表在它下面长出来。鼠标从药丸移到列表不用离开窗口,也没有那个 +# "两个窗口之间的空档"。 +# --------------------------------------------------------------------------- + +function Read-SessionsFile { + $file = Join-Path $configDir 'sessions.json' + if (!(Test-Path -LiteralPath $file)) { return @() } + try { + $raw = [System.IO.File]::ReadAllText($file) + $j = $raw | ConvertFrom-Json + return @($j.sessions) + } catch { + return @() + } +} + +# 本地"已消除、detector 还没确认"的会话。 +# +# 点 × 之后 detector 要等下一轮(最多 1 秒)才把 ack 消费掉、才把它从 +# sessions.json 里拿掉。在这一秒里列表还读得到这一行 —— 用户点了 × 而行 +# 还在屏幕上,看起来就是"没点动"。 +# +# 所以 widget 自己先记一份:Show-SessionList 渲染时把它们藏掉。detector +# 消费掉之后这个 id 就不再出现在 sessions.json 里,pending 集合自然收敛 +# (见 Show-SessionList 里的清理)。 +$script:pendingDismiss = @{} + +# 点 × 之前的前台窗口。 +# +# 鼠标按下会让 Windows 把焦点交给被点的窗口 —— 灵动岛自己就成了前台。 +# 对"跳到那个会话"来说这是对的(本来就该跳过去)。对"只消掉"来说是反的: +# 用户的意图是"别管这一条,让我继续在编辑器里干活",结果焦点被抢到灵动岛 +# 上,编辑器失去焦点,下一次按键就落进了不知道哪儿去。 +# +# 所以在按下之前记下前台是谁,处理完再还回去。 +# +# 记的时机是 MouseEnter(鼠标进入药丸)而不是 MouseDown:MouseEnter 一定 +# 早于任何一次点击,而且此时焦点还在用户原来的窗口上。 +$script:foregroundBeforeAction = [IntPtr]::Zero + +# 灵动岛自己的窗口句柄。 +# +# 用来判断"当前前台是不是我自己" —— 是的话就不用还(还给自己没有意义)。 +function Get-WidgetHwnd { + try { + $helper = New-Object System.Windows.Interop.WindowInteropHelper($window) + return $helper.Handle + } catch { + return [IntPtr]::Zero + } +} + +# 记下当前前台窗口,供消除之后归还。 +# +# 只记"不是灵动岛自己"的前台:自己激活自己的时候没什么可还的。 +function Save-ForegroundBeforeAction { + try { + $fg = [WinAPI]::GetForegroundWindow() + if ($fg -eq [IntPtr]::Zero) { return } + $self = Get-WidgetHwnd + if ($self -ne [IntPtr]::Zero -and $fg -eq $self) { return } + $script:foregroundBeforeAction = $fg + } catch { + # 记不住就算了:宁可焦点留在灵动岛,也不要在这里抛异常把点击搞崩。 + } +} + +# 把焦点还给之前记下的窗口。 +# +# 用 SetForegroundWindow:它只改"哪个窗口在前台",不动 z-order、不动大小 +# 位置、不最大化。用户的窗口保持原样,只是重新拿到焦点。 +# +# SetForegroundWindow 可能被 Windows 的前台锁挡下来(调用方不是当前前台 +# 进程时)。所以抢完要回读一次前台窗口确认真的还回去了,不成功就记一笔 +# 日志 —— 静默失败会让人以为"消除会还焦点"是假的。 +function Restore-ForegroundAfterAction { + $target = $script:foregroundBeforeAction + if ($target -eq [IntPtr]::Zero) { return } + try { + if (-not [WinAPI]::IsWindow($target)) { return } + [WinAPI]::SetForegroundWindow($target) | Out-Null + $now = [WinAPI]::GetForegroundWindow() + if ($now -eq $target) { + Dbg ("foreground restored to hwnd=" + $target) + } else { + Dbg ("foreground NOT restored (wanted hwnd=" + $target + ", now=" + $now + ")") + } + } catch { + Dbg ("FOREGROUND RESTORE FAIL: " + $_.Exception.Message) + } +} + +# 写 ack.signal:告诉 detector 消掉哪个会话。 +# +# 一行一个 id,可以写多行。 +# +# 为什么不是"覆盖写一个 id":detector 每秒消费一次 ack.signal。用户在一秒 +# 里点两行(点两下 ×,或者点 × 之后立刻点另一行),后写的会盖掉先写的, +# 那一条就永远消不掉。消除是个批量动作,一秒内消掉两三条很正常,所以这里 +# 改成追加。 +# +# 先写临时文件再改名:detector 那边也是靠改名消费 ack.signal。两边都用 +# 改名,就不存在"读到写了一半的文件"这一种情况。 +function Write-AckSignal([string]$id) { + if (-not $id) { return $false } + $ackFile = Join-Path $configDir 'ack.signal' + $tmp = $ackFile + '.tmp' + try { + $existing = '' + if (Test-Path -LiteralPath $ackFile) { + try { $existing = [System.IO.File]::ReadAllText($ackFile) } catch { $existing = '' } + } + $text = $existing + if ($text.Length -gt 0 -and -not $text.EndsWith("`n")) { $text = $text + "`n" } + $text = $text + $id + "`n" + [System.IO.File]::WriteAllText($tmp, $text, (New-Object System.Text.UTF8Encoding($false))) + Move-Item -LiteralPath $tmp -Destination $ackFile -Force + return $true + } catch { + Dbg "SESSION ACK FAIL: $($_.Exception.Message)" + try { if (Test-Path -LiteralPath $tmp) { Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue } } catch {} + return $false + } +} + +function Clear-SessionRows { + if (-not $script:sessionRows) { return } + $script:sessionRows.Children.Clear() +} + +# 列表里每一项的高度(含内边距和分隔线)。 +# +# 两行(标题 + 摘要)加上下各 7px 的内边距,约 48px。写成一个常量, +# 是因为 Show-SessionList 要用它算窗口高度,而这里的内容一改就得重算。 +# 实测校准:标题 12.5px 行高约 17 + 摘要 11px 行高约 15 + 行距 2 +# + 上下内边距 14 + 分隔线 1 = 49。留一点余量取 48。 +$script:sessionRowHeight = 48 + +# 屏幕上最多画几行。 +# +# detector 已经把数据侧限制在 $script:MAX_SESSIONS(20)条,但 20 行 × 48px +# 已经 960px,比多数人的可用高度还高 —— 直接按条数长窗口,列表底部会跑到 +# 屏幕外面去,最后几条永远看不到,还挡着别的窗口。 +# +# 所以这里按"窗口能放下"再裁一次,并且优先留最上面那几条(排序已经把 +# 需要用户看的放前面了)。多出来的条数在列表底部用一行"还有 N 条"交代, +# 不静默截断。 +$script:MAX_SESSION_ROWS = 12 + +# 相对时间:近的给"多久之前",远的给钟点。 +# +# 和小红书那种消息列表一个路子:60 分钟以内说"3 分钟前"(精确到分钟就 +# 够了),再早就给 HH:mm("3 小时前"不如"13:09"精确)。 +function Format-RelativeTime([int64]$tsMs) { + if ($tsMs -le 0) { return '' } + $diff = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() - $tsMs + if ($diff -lt 0) { $diff = 0 } + if ($diff -lt 60000) { return '刚刚' } + if ($diff -lt 3600000) { return ([int][math]::Floor($diff / 60000)).ToString() + ' 分钟前' } + $t = [DateTimeOffset]::FromUnixTimeMilliseconds($tsMs).ToLocalTime() + return $t.ToString('HH:mm') +} + +# 列表的一行。 +# +# 布局参考小红书的展开态:左边一个状态点,中间两行字(标题 + 摘要), +# 右边时间戳。 +# +# 摘要那行是它比原来多出来的东西:光看首条提问,分不清"正在跑"和"卡在 +# 某个工具上";看最后一句才知道它现在在说什么、要不要我回。 +# +# [●] 标题………………………… 3 分钟前 +# 摘要………………………… 运行中 +# + # 状态点跨两行、垂直居中;标题和摘要都在中间列,所以天然左对齐。 + # + # 颜色用**面板那一极**的系统色表:列表行画在面板上,面板是暗是亮由它 + # 自己背后的亮度决定。药丸和面板可以是不同的极,所以不能共用一份。 + function Add-SessionRow([string]$id, [string]$title, [string]$phase, [string]$preview, [string]$lastTool, [int64]$lastAtMs, [bool]$isLast) { + if (-not $script:sessionRows) { return } + # 状态 -> 文案 + 颜色。 + # + # 用的是 pill 那套状态词表(working/thinking/waiting/idle/done/error), + # 颜色也跟着 pill 走:同一个状态在两个地方就是同一个颜色、同一句话。 + # 之前这里自己写了一套 running/waiting/stopped,于是同一个会话在 pill + # 上显示"等用户"、在列表里显示"运行中",pill 有的"已完成"列表里更是 + # 根本没有。 + # + # "running" 是旧词表的值,留着当兼容:过渡期里 sessions.json 可能还是 + # 旧的。 + # 文案在这里,颜色去 pill 那张表里取 —— 同一个状态在两个地方必须是同 + # 一个颜色、同一句话。 + # + # 之前这张表自带一套 Tailwind 色(#3B82F6 之类),跟 pill 用的 Apple 系 + # 统色(#0A84FF)不是一回事,"颜色也跟着 pill 走"就成了空话:同一个 + # "运行中",药丸上是系统蓝、列表里是另一种蓝。现在两边共用一张表。 + $stateTextMap = @{ + 'working' = @{ text = '运行中'; state = 'working' } + 'thinking' = @{ text = '思考中'; state = 'thinking' } + 'waiting' = @{ text = '等待审批'; state = 'waiting' } + 'idle' = @{ text = '待回复'; state = 'idle' } + 'done' = @{ text = '已完成'; state = 'done' } + 'error' = @{ text = '出错'; state = 'error' } + 'stopped' = @{ text = '已停止'; state = 'idle' } + 'running' = @{ text = '运行中'; state = 'working' } + } + $stateInfo = $stateTextMap[[string]$phase] + if (-not $stateInfo) { $stateInfo = $stateTextMap['idle'] } + $phaseText = $stateInfo.text + # 颜色跟**面板**当前极走:深色极用暗色系统色,浅色极用浅色系统色。 + $smap = Get-PanelStateMap + $dotHex = $smap[$stateInfo.state].dot + if (-not $dotHex) { $dotHex = $smap['idle'].dot } + + # 第二行:正文优先。没有正文(最后一条恰好是 toolCall)就退到"正在 + # 调用的工具名",总比空着强。 + $subText = '' + if ($preview) { $subText = $preview } + elseif ($lastTool) { $subText = '执行 ' + $lastTool } + if ($subText.Length -gt 48) { $subText = $subText.Substring(0, 48) + '…' } + + $grid = New-Object System.Windows.Controls.Grid + $grid.Cursor = [System.Windows.Input.Cursor]::Hand + + # 三列:状态点(自适应) | 文字(占满剩余) | 右侧时间(自适应)。 + # + # 中间列必须是 * 才能被裁剪(TextTrimming 只在有有限宽度时才生效)。 + $colDot = New-Object System.Windows.Controls.ColumnDefinition + $colDot.Width = New-Object System.Windows.GridLength(1, [System.Windows.GridUnitType]::Auto) + $colTitle = New-Object System.Windows.Controls.ColumnDefinition + $colTitle.Width = New-Object System.Windows.GridLength(1, [System.Windows.GridUnitType]::Star) + $colTag = New-Object System.Windows.Controls.ColumnDefinition + $colTag.Width = New-Object System.Windows.GridLength(1, [System.Windows.GridUnitType]::Auto) + # 最右边一列:消除按钮(×)。固定宽度,不参与 Star 分配 —— 它必须一直 + # 在同一个位置,否则鼠标移到不同行时 × 会左右跳。 + $colX = New-Object System.Windows.Controls.ColumnDefinition + $colX.Width = New-Object System.Windows.GridLength(1, [System.Windows.GridUnitType]::Auto) + $grid.ColumnDefinitions.Add($colDot) | Out-Null + $grid.ColumnDefinitions.Add($colTitle) | Out-Null + $grid.ColumnDefinitions.Add($colTag) | Out-Null + $grid.ColumnDefinitions.Add($colX) | Out-Null + + # 两行:标题 / 摘要。 + $rowTop = New-Object System.Windows.Controls.RowDefinition + $rowTop.Height = New-Object System.Windows.GridLength(1, [System.Windows.GridUnitType]::Auto) + $rowBot = New-Object System.Windows.Controls.RowDefinition + $rowBot.Height = New-Object System.Windows.GridLength(1, [System.Windows.GridUnitType]::Auto) + $grid.RowDefinitions.Add($rowTop) | Out-Null + $grid.RowDefinitions.Add($rowBot) | Out-Null + + $dot = New-Object System.Windows.Shapes.Ellipse + $dot.Width = 8; $dot.Height = 8 + $dot.Fill = (New-Object System.Windows.Media.SolidColorBrush ([System.Windows.Media.ColorConverter]::ConvertFromString($dotHex))) + $dot.VerticalAlignment = [System.Windows.VerticalAlignment]::Center + $dot.Margin = New-Object System.Windows.Thickness(0,0,10,0) + + # 前景色跟着**面板**当前极走:深色极是白字,浅色极是深字。 + $ink = Get-PanelInk + + $txt = New-Object System.Windows.Controls.TextBlock + $txt.Text = if ($title) { $title } else { $id } + $txt.Foreground = C $ink.Primary + $txt.FontSize = 13 + $txt.TextTrimming = [System.Windows.TextTrimming]::CharacterEllipsis + $txt.VerticalAlignment = [System.Windows.VerticalAlignment]::Center + + $sub = New-Object System.Windows.Controls.TextBlock + $sub.Text = $subText + $sub.Foreground = C $ink.Secondary + $sub.FontSize = 11 + $sub.TextTrimming = [System.Windows.TextTrimming]::CharacterEllipsis + $sub.VerticalAlignment = [System.Windows.VerticalAlignment]::Center + $sub.Margin = New-Object System.Windows.Thickness(0,2,0,0) + + # 右列:时间戳 + 状态文字,整块垂直居中于两行(和小红书那个时间戳的 + # 位置一样)。 + $right = New-Object System.Windows.Controls.StackPanel + $right.VerticalAlignment = [System.Windows.VerticalAlignment]::Center + $right.HorizontalAlignment = [System.Windows.HorizontalAlignment]::Right + $right.Margin = New-Object System.Windows.Thickness(8,0,0,0) + + $time = New-Object System.Windows.Controls.TextBlock + $time.Text = (Format-RelativeTime $lastAtMs) + $time.Foreground = C $ink.Tertiary + $time.FontSize = 11 + $time.HorizontalAlignment = [System.Windows.HorizontalAlignment]::Right + + $tag = New-Object System.Windows.Controls.TextBlock + $tag.Text = $phaseText + $tag.Foreground = (New-Object System.Windows.Media.SolidColorBrush ([System.Windows.Media.ColorConverter]::ConvertFromString($dotHex))) + $tag.FontSize = 11 + $tag.HorizontalAlignment = [System.Windows.HorizontalAlignment]::Right + + $right.Children.Add($time) | Out-Null + $right.Children.Add($tag) | Out-Null + + # 消除按钮(×)。 + # + # 用 TextBlock 而不是 Button:Button 默认带边框和背景,在这个无边框的 + # 透明窗口里会画出一个灰底方块,很难看。TextBlock 自己画一个字就够。 + # + # 键盘可达性是个已知短板:一个纯鼠标点的按钮没法用 Tab 走到。它挂在 + # 一个本来就只能鼠标点的浮层列表上,这个列表整体没有键盘导航,先不 + # 单独给 × 造一套焦点管理。 + $xMark = New-Object System.Windows.Controls.TextBlock + $xMark.Text = '×' + $xMark.FontSize = 15 + $xMark.Foreground = C $ink.Tertiary + $xMark.VerticalAlignment = [System.Windows.VerticalAlignment]::Center + $xMark.HorizontalAlignment = [System.Windows.HorizontalAlignment]::Center + $xMark.Margin = New-Object System.Windows.Thickness(10,0,2,0) + $xMark.Cursor = [System.Windows.Input.Cursor]::Hand + $xMark.ToolTip = '从列表中消除(不跳转)' + # 键盘可达:TextBlock 默认不可聚焦,Tab 走不到它,Enter/Space 也没有人会按。 + # 这个列表整个是浮层,本来就得用鼠标唤出,但一旦 × 拿到了焦点,键盘必须也 + # 能干活 —— 否则"消除"就只剩一种输入方式。 + $xMark.Focusable = $true + + # hover 时变红:让它一眼看出这是个"会动手"的东西。 + $xIdleBrush = $xMark.Foreground + $xHotBrush = (New-Object System.Windows.Media.SolidColorBrush ([System.Windows.Media.ColorConverter]::ConvertFromString('#FFFF453A'))) + # $args[0] 是 sender(这个 TextBlock),和下面 MouseLeftButtonDown 用 + # $args[1] 取事件参数是同一套写法。 + $xMark.Add_MouseEnter({ $args[0].Foreground = $xHotBrush }.GetNewClosure()) + $xMark.Add_MouseLeave({ $args[0].Foreground = $xIdleBrush }.GetNewClosure()) + + # 点 × :只消除,不跳转。 + # + # Handled = true 是必须的:× 是 Border 的子元素,MouseLeftButtonDown 会 + # 冒泡到 Border,而 Border 上挂着整行的点击处理(消掉 + 跳转)。不拦住 + # 的话,点 × 会顺手把用户切到那个会话去 —— 正好是 × 要避免的事。 + # + # 焦点:鼠标按下会把焦点交给灵动岛自己。消除完要还回去(见 + # Invoke-SessionDismiss 末尾的 Restore-ForegroundAfterAction)。 + $xMark.Add_MouseLeftButtonDown({ + $args[1].Handled = $true + Invoke-SessionDismiss $id + }.GetNewClosure()) + + # 键盘:Enter / Space 等同于点一下 ×。 + # + # 焦点在 × 上时按这两个键,消掉这一行 —— 和鼠标点一下是同一件事,不切换 + # 会话。Tab 在几行 × 之间移动(列表里可聚焦的就是它们)。 + $xMark.Add_KeyDown({ + $key = [string]$args[1].Key + # WPF 把主键盘的 Enter 报成 Return,数字键盘上那个才叫 Enter —— 两种都收, + # 少收一种就等于键盘这条路是死的。 + if ($key -ne 'Return' -and $key -ne 'Enter' -and $key -ne 'Space') { return } + $args[1].Handled = $true + Dbg ("session dismissed by keyboard: " + $key + " " + $id) + Invoke-SessionDismiss $id + }.GetNewClosure()) + + $grid.Children.Add($dot) | Out-Null + $grid.Children.Add($txt) | Out-Null + $grid.Children.Add($right) | Out-Null + $grid.Children.Add($sub) | Out-Null + $grid.Children.Add($xMark) | Out-Null + [System.Windows.Controls.Grid]::SetColumn($dot, 0) + [System.Windows.Controls.Grid]::SetColumn($txt, 1) + [System.Windows.Controls.Grid]::SetColumn($right, 2) + [System.Windows.Controls.Grid]::SetColumn($sub, 1) + [System.Windows.Controls.Grid]::SetColumn($xMark, 3) + [System.Windows.Controls.Grid]::SetRow($dot, 0) + [System.Windows.Controls.Grid]::SetRow($txt, 0) + [System.Windows.Controls.Grid]::SetRow($right, 0) + [System.Windows.Controls.Grid]::SetRow($sub, 1) + # 状态点和右列各跨两行:状态点在整块左边居中,时间戳在整块右边居中。 + $dot.SetValue([System.Windows.Controls.Grid]::RowSpanProperty, 2) + $right.SetValue([System.Windows.Controls.Grid]::RowSpanProperty, 2) + + # 项之间的分隔线。最后一项不带线(下面就是面板底边)。 + $border = New-Object System.Windows.Controls.Border + $border.BorderBrush = (New-Object System.Windows.Media.SolidColorBrush ([System.Windows.Media.ColorConverter]::ConvertFromString('#1FFFFFFF'))) + $border.BorderThickness = $(if ($isLast) { New-Object System.Windows.Thickness(0) } else { New-Object System.Windows.Thickness(0,0,0,1) }) + $border.Padding = New-Object System.Windows.Thickness(0,7,0,7) + $border.Child = $grid + + # 提示框:标题 + 摘要 + 状态 + 时间,一次说全。 + $tip = if ($title) { $title } else { $id } + if ($subText) { $tip = $tip + "`n" + $subText } + $rel = Format-RelativeTime $lastAtMs + if ($rel) { $tip = $tip + "`n" + $phaseText + ' · ' + $rel } else { $tip = $tip + "`n" + $phaseText } + $border.ToolTip = $tip + + # 点击挂在最外层的 Border 上,子元素不再挂: + # + # MouseLeftButtonDown 会冒泡。挂子元素时,Border + Grid + TextBlock 各挂 + # 一份就会一次点击触发多次消掉。现在只有最外层一个 handler,点整行任何 + # 位置都算点这一行。 + # + # Handled=true 是必须的:窗口上挂了拖拽处理器(MouseLeftButtonDown), + # 列表在同一个窗口里,不拦住的话点一行会被当成"开始拖药丸"。 + $border.Add_MouseLeftButtonDown({ + $args[1].Handled = $true + # 第二道保险:× 自己已经设了 Handled,理论上事件到不了这里。但 + # "理论上"不够 —— 万一某个 WPF 版本的冒泡行为不同,点 × 就会顺手 + # 把用户切到那个会话去,正好是 × 要避免的事。所以再查一次事件源。 + if ($args[1].OriginalSource -eq $xMark) { return } + Invoke-SessionClick $id + }.GetNewClosure()) + + $script:sessionRows.Children.Add($border) | Out-Null +} + +# 列表被行数上限截掉时,在底部补一行说明还剩多少条。 +# +# 静默截断最糟:用户看到 12 行,以为就这么多,实际上还有 5 个会话在跑 —— +# 而"还有东西在别处"正是这个列表要解决的问题。这一行不可点,只是交代。 +function Add-SessionOverflowRow([int]$hidden) { + if (-not $script:sessionRows) { return } + if ($hidden -le 0) { return } + $tb = New-Object System.Windows.Controls.TextBlock + $tb.Text = '还有 ' + $hidden + ' 条' + $tb.Foreground = C (Get-PanelInk).Muted + $tb.FontSize = 10.5 + $tb.HorizontalAlignment = [System.Windows.HorizontalAlignment]::Center + $tb.Margin = New-Object System.Windows.Thickness(0,6,0,2) + $script:sessionRows.Children.Add($tb) | Out-Null +} + +function Show-SessionList { + if (-not $script:sessionRows) { return } + # 取消待收起的定时器。 + # + # 这一步不能只放在 MouseEnter 里:展开有三个入口(MouseEnter、 + # MouseMove 兜底、手动调用),少一个就可能出现"展开后 300ms 立刻收起" + # 的闪烁 —— 展开把窗口撑高,收起又把高度改回去,窗口反复跳。 + if ($script:listHideTimer) { + try { $script:listHideTimer.Stop() } catch {} + $script:listHideTimer = $null + } + $items = Read-SessionsFile + if (-not $items -or $items.Count -eq 0) { Hide-SessionList; return } + # 把"本地已消除、detector 还没确认"的先藏掉。 + # + # 点 × 之后 detector 要等下一轮才消费 ack,这期间这一行还在 sessions.json + # 里。藏掉它,用户才觉得"点了就走了"。 + # + # 同时反过来收敛:某个 id 已经不在 sessions.json 里了,说明 detector 已经 + # 消费掉它、确认了消除 —— 这时把它从 pending 里划掉,pending 不会一直长。 + # + # 但光靠"不在 sessions.json 里"这一条会漏掉一种情况:detector 把这个 id + # 消掉之后**又把它跟踪回来了**(同一个会话又活过来了)。中间"不在"的 + # 那一刻正好夹在两次 Show-SessionList 之间,我们根本观察不到,于是这个 + # id 永远留在 pending 里,这一行也就永远不再显示 —— 整个列表从此空着, + # 灵动岛看起来像坏了。 + # + # 所以再加一条:pending 最多留 4 秒。detector 消费 ack 是秒级的,4 秒 + # 足够它确认;到点还没确认,就当这个 id 回来了,别再藏了。 + if ($script:pendingDismiss.Count -gt 0) { + $present = @{} + foreach ($s in $items) { $present[[string]$s.id] = $true } + $now = [DateTime]::UtcNow + foreach ($pid_ in @($script:pendingDismiss.Keys)) { + $stamp = $script:pendingDismiss[$pid_] + $gone = (-not $present.ContainsKey($pid_)) + $expired = $false + if ($stamp -is [datetime]) { $expired = (($now - $stamp).TotalSeconds -gt 4) } + if ($gone -or $expired) { $script:pendingDismiss.Remove($pid_) } + } + $items = @($items | Where-Object { -not $script:pendingDismiss.ContainsKey([string]$_.id) }) + if (-not $items -or $items.Count -eq 0) { Hide-SessionList; return } + } + Clear-SessionRows + # 运行中的排前面,其余按"最后一次动静"倒序。 + # + # 排序用 lastAtMs(最后活动时间),不是 sinceMs(进列表的时间): + # 进列表的时间只说明"我什么时候看见它",说明不了"它现在有多活跃"。 + # + # 顺序:在跑的排最前,然后是等你的、再是等完了的,停了的排最后。 + $stateOrder = @{ + 'working' = 0; 'thinking' = 0; 'waiting' = 1; 'idle' = 2 + 'done' = 3; 'error' = 4; 'stopped' = 5; 'running' = 0 + } + $sorted = @($items | Sort-Object @{ Expression = { $k = [string]$_.phase; if ($stateOrder.ContainsKey($k)) { $stateOrder[$k] } else { 2 } } }, @{ Expression = { if ($_.lastAtMs) { [int64]$_.lastAtMs } else { [int64]$_.sinceMs } }; Descending = $true }) + # 画几行是有上限的:排序已经把最该看的放前面,超出的部分不画,但也不 + # 装作没有 —— 底下补一行"还有 N 条",把窗口高度框死在屏幕里。 + $total = $sorted.Count + $hidden = 0 + if ($total -gt $script:MAX_SESSION_ROWS) { + $hidden = $total - $script:MAX_SESSION_ROWS + $sorted = @($sorted | Select-Object -First $script:MAX_SESSION_ROWS) + } + $i = 0 + foreach ($s in $sorted) { + Add-SessionRow ([string]$s.id) ([string]$s.title) ([string]$s.phase) ([string]$s.preview) ([string]$s.lastTool) ([int64]$s.lastAtMs) ($i -lt ($sorted.Count - 1) -and $hidden -eq 0) + $i++ + } + if ($hidden -gt 0) { Add-SessionOverflowRow $hidden } + # 列表高度不能按"行数 × 猜的行高"算。 + # + # 那样算有两处错:摘要会换行,一项不止那么高;而且 SessionPanel 自己 + # 还要占上下 Margin。两行时内容需要 106px,row 只分到 94px,最后一行 + # 就被裁掉半行 —— 窗口底部还空着 14px,看起来更莫名其妙。 + # + # 所以让布局自己算:row 1 改成 Auto,Grid 就按内容测量,再拿实际需要 + # 的高度去撑窗口。DesiredSize 是"内容需要多高",不受窗口当前高度影响。 + $script:sessionRowsPanel.Visibility = [System.Windows.Visibility]::Visible + $script:sessionListRow.Height = [System.Windows.GridLength]::Auto + $script:sessionRowsPanel.UpdateLayout() + # DesiredSize 里没有 Margin,所以自己加上面板的上边距(6)和下留白。 + $listH = [int][Math]::Ceiling($script:sessionRowsPanel.DesiredSize.Height) + 6 + $script:glassMarginPanelBottom + if ($listH -le 0) { $listH = ($sorted.Count * $script:sessionRowHeight) + 26 } + # 兜底再夹一次:上面按行数裁过,但一行的高度会随字号/DIP 缩放变,理论上 + # 仍可能超出屏幕。窗口一旦比工作区高,底部(也就是最后几条)跑到屏幕外, + # 还挡住底下的窗口 —— 那种"列表画了但看不见"比不画更难查。 + try { + $wa = [WinAPI]::GetWorkAreaForWindow($window) + if ($wa.Left -ne -1) { + $maxH = ($wa.Bottom - $wa.Top) - $window.Top + $chrome = $script:pillHeight + 2 * $script:glassMargin + if ($maxH -gt $chrome -and ($chrome + $listH) -gt $maxH) { + $listH = [int][Math]::Floor($maxH) - $chrome + Dbg ("session list clamped to the work area: listH=" + $listH) + } + } + } catch {} + $window.Height = $script:pillHeight + 2 * $script:glassMargin + $listH + # 窗口刚变高,底下那块屏幕变了 —— 磨砂背景要重抓一次,材质几何也要 + # 按新尺寸重算(面板高度变了,圆角/路径必须跟上)。 + # 放在设置 Height 之后:抓的是**当前**窗口尺寸对应的区域。 + try { Sync-GlassGeometry } catch {} + try { [void](Update-GlassBackdrop) } catch {} + Dbg ("session list shown: " + $sorted.Count + " hidden=" + $hidden + " winH=" + $window.Height + " listH=" + $listH) +} + +function Hide-SessionList { + if ($script:listHideTimer) { + try { $script:listHideTimer.Stop() } catch {} + $script:listHideTimer = $null + } + if ($script:sessionRowsPanel) { + $script:sessionRowsPanel.Visibility = [System.Windows.Visibility]::Collapsed + } + if ($script:sessionListRow) { + $script:sessionListRow.Height = New-Object System.Windows.GridLength(0) + } + if ($window) { $window.Height = $script:pillHeight + 2 * $script:glassMargin } +} + +# 宽限期之后才收起。鼠标从药丸移到列表会经过一段"两个窗口都不是"的地带, +# 没有宽限期列表会在鼠标到达之前就消失。 +function Schedule-SessionListHide { + if (-not $script:sessionRowsPanel) { return } + if ($script:listHideTimer) { try { $script:listHideTimer.Stop() } catch {} } + $script:listHideTimer = New-Object System.Windows.Threading.DispatcherTimer + $script:listHideTimer.Interval = [TimeSpan]::FromMilliseconds(300) + $script:listHideTimer.Add_Tick({ + try { $script:listHideTimer.Stop() } catch {} + Hide-SessionList + }) + $script:listHideTimer.Start() +} + +# 点一行:消掉它 + 切过去。 +function Invoke-SessionClick([string]$id) { + if (-not $id) { return } + # 会话 id 来自 sessions.json,而那个文件可能被手改过(或者上一次崩在半路)。 + # 它现在**不**会被拼进任何命令,但白名单仍然值得有:一个不该出现的 id + # 说明这个条目不可信,别拿它去写 ack、也别拿它去匹配窗口。 + # + # 与 scripts/lib/Session-Tracker.ps1 的 $script:SESSION_ID_PATTERN 同源; + # detector 那边在入口就把不合法的丢掉了,这里是 widget 侧的同一道门。 + if ($id -notmatch '^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$') { + Dbg "session click: refused an id that is not shaped like a session id" + return + } + # 消掉:写 ack.signal,detector 收走(它会从列表里删掉这一条,并且 + # 在这个 session 跑完下一轮之前不再把它收回来)。 + try { + if (Write-AckSignal $id) { + Dbg "session ack written: $id" + } + } catch { + Dbg "SESSION ACK FAIL: $($_.Exception.Message)" + } + Hide-SessionList + # 先试"切到已经开着这个会话的终端 tab"。切到了就不用再开一个 —— + # 新开一个 mcode --session 会让同一个会话有两个终端在跑。 + $names = @() + $cur = '' + try { + foreach ($s in @(Read-SessionsFile)) { + if ([string]$s.id -eq $id) { + if ($s.title) { $cur = [string]$s.title } + $names = @(@($s.titles) | Where-Object { $_ }) + break + } + } + } catch {} + # 终端 tab 上是**历史**名字,app 侧边栏上是**当前**名字,所以当前名字排前面。 + if ($cur) { $names = @($cur) + @($names | Where-Object { $_ -and $_ -ne $cur }) } + if ($names.Count -eq 0 -and $cur) { $names = @($cur) } + if (Select-SessionTerminal $names) { + Dbg ("session terminal switched: " + $id + " foregroundPid=" + (Get-ForegroundPid)) + return + } + # 桌面端:会话多半住在 app 里,没有终端标签可切 —— 去 app 的侧边栏点那一行。 + if (Switch-ToAppConversation $names) { + Dbg ("app conversation switched: " + $id + " foregroundPid=" + (Get-ForegroundPid)) + return + } + # 形态相关的兜底。 + if ($script:clickPrefers -eq 'app') { + # 桌面端:会话在 app 里,点不到那一行也说明 app 开着 —— 把 app 窗口带到 + # 前台。**绝不**新开终端:那会在用户桌面上多出一个跑着同一个会话的 + # 终端,而这个功能存在的意义就是"回到原来那个"。 + if (Activate-AppWindow) { + Dbg ("app window activated (conversation not matched): " + $id) + return + } + } else { + # CLI:会话名对不上只该影响"切到哪个标签",不该造成"同一个会话两个终端"。 + # 先把终端窗口带到前台。 + if (Activate-TerminalWindow) { + Dbg ("session terminal activated (tab not matched): " + $id + " foregroundPid=" + (Get-ForegroundPid)) + return + } + } + # 走到这里说明:没有匹配的终端标签,也没有 app 窗口可点。 + # + # **失败关闭**:不新开终端。 + # + # 以前这里会 `Start-SessionTerminal $id` 开一个 "mcode --session "。 + # 两处不对: + # 1. 它把 id 拼进命令字符串交给 powershell -Command —— 一个形如 + # `x; Set-Content ...` 的 id 就能执行任意 PowerShell; + # 2. 就算 id 是干净的,新开的终端也可能和**已经在跑**的那个会话重复, + # 正是这个功能要消除的局面。 + # + # "找不到"本身不是开一个新的理由:会话可能跑在一个我们看不见的宿主里 + # (app 内嵌终端、别的终端程序),那时候新开只会多一个。宁可什么都不做, + # 让用户自己切 —— 日志里写清楚为什么。 + Dbg ("session click: no target for " + $id + " (no matching tab, no app window) -- not opening a new terminal") +} + +# 消除一个会话,但不跳过去。 +# +# 点整行 = 消掉 + 跳到那个会话。跳转会抢焦点、会把用户从当前正在做的事 +# 里拽走 —— 而"我不想管这一条"和"我要去这一条"是两回事:有时候列表里那 +# 几条就是不想再看了(比如已经处理完的),但你并不想因此切走手头的事。 +# +# 所以每行右边给一个 ×:只消掉,不跳转,不关列表(消完还能接着消下一条)。 +# +# 消掉的语义沿用 detector 那边的 ack 协议:这一轮里它不再出现,等 agent +# 跑完一轮又重新开始,它会自己回来。跟点整行的消掉是同一套规则。 +function Invoke-SessionDismiss([string]$id) { + if (-not $id) { return } + # 与 Invoke-SessionClick 同一道门:id 来自 sessions.json,不可信。 + if ($id -notmatch '^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$') { + Dbg "session dismiss: refused an id that is not shaped like a session id" + return + } + if (-not (Write-AckSignal $id)) { return } + # 先本地记下:detector 要到下一轮(最多 1 秒)才消费 ack,这期间这一行 + # 还在 sessions.json 里。不先藏掉,用户点了 × 而行还在屏幕上。 + # 记时间戳而不只是 true:Show-SessionList 靠它判断"这个 pending 是不是 + # 该过期了"(见那里的说明)。 + $script:pendingDismiss[$id] = [DateTime]::UtcNow + Dbg ("session dismissed without switching: " + $id) + # 重画:这一行立刻消失,高度也跟着收。 + try { Show-SessionList } catch { Dbg "SESSION LIST REFRESH FAIL: $($_.Exception.Message)" } + # 最后把焦点还回去。 + Restore-ForegroundAfterAction +} + +# 找到 app 侧边栏里的**会话列表容器**。 +# +# 不能在整个窗口里按名字找按钮:窗口里别的地方也可能有一个同名的按钮 +# (标题栏、别的面板),点错了就不是"切到那个会话"。所以先找到**装着 +# 多个会话标题**的那个容器 —— 侧边栏的会话列表 —— 再只在里面找目标。 +# +# 会话标题是已知的(会话列表自己就是),所以判据很硬:容器里至少要有 +# 两个已知的会话标题。整扇窗口不会满足这个条件。 +function Get-AppConversationList([IntPtr]$hwnd, [string[]]$knownTitles) { + if (-not $knownTitles -or $knownTitles.Count -eq 0) { return $null } + try { + if (-not $script:uiaReady) { + Add-Type -AssemblyName UIAutomationClient -ErrorAction Stop + Add-Type -AssemblyName UIAutomationTypes -ErrorAction Stop + $script:uiaReady = $true + } + $root = [System.Windows.Automation.AutomationElement]::FromHandle($hwnd) + if (-not $root) { return $null } + + # 第一步:把窗口里所有"名字是某个会话标题"的元素收起来。 + $candidates = New-Object System.Collections.Generic.List[object] + foreach ($n in $knownTitles) { + $nn = ([string]$n).Trim() + if (-not $nn) { continue } + $cond = New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::NameProperty, $nn) + $hits = $root.FindAll([System.Windows.Automation.TreeScope]::Descendants, $cond) + for ($i = 0; $i -lt $hits.Count; $i++) { $candidates.Add($hits.Item($i)) } + } + if ($candidates.Count -lt 2) { + Dbg ("app conversation list: only " + $candidates.Count + " candidate(s) for " + $knownTitles.Count + " known title(s)") + return $null + } + + # 第二步:从候选往上走,找到**装着至少两个候选的最浅的那一层祖先** + # —— 那就是会话列表本身。 + # + # 为什么要"最浅":往上走是往窗口根走,越浅的祖先装的东西越少。候选 + # 按钮自己的祖先里,第一个装着多个会话的,就是侧边栏的会话列表;再往 + # 上就变成整扇窗口了(那等于没限定)。 + $walker = [System.Windows.Automation.TreeWalker]::ControlViewWalker + $best = $null + $bestDepth = 99 + foreach ($c in $candidates) { + $node = $c + $depth = 0 + while ($node -ne $null -and $depth -lt 12) { + $inside = 0 + try { + $sub = $node.FindAll([System.Windows.Automation.TreeScope]::Descendants, + [System.Windows.Automation.Condition]::TrueCondition) + foreach ($e in $sub) { + try { + $nm = $e.Current.Name + if ($knownTitles -contains $nm) { $inside++ } + } catch {} + } + } catch { $inside = 0 } + if ($inside -ge 2 -and $depth -lt $bestDepth) { $best = $node; $bestDepth = $depth } + try { $node = $walker.GetParent($node) } catch { break } + $depth++ + } + } + Dbg ("app conversation list: " + $candidates.Count + " candidate(s), container depth=" + $bestDepth) + return $best + } catch { + Dbg ("APP CONVERSATION LIST FAIL: " + $_.Exception.Message) + return $null + } +} + +# 在侧边栏的会话列表里找那个名字等于会话标题的行。 +# +# 查找范围由 Get-AppConversationList 限定在会话列表容器内,不再是整扇 +# 窗口。找不到就返回 null,让调用方走自己的兜底。 +function Get-AppConversationButton([IntPtr]$hwnd, [string]$name, [string[]]$knownTitles) { + if (-not $name) { return $null } + try { + if (-not $script:uiaReady) { + Add-Type -AssemblyName UIAutomationClient -ErrorAction Stop + Add-Type -AssemblyName UIAutomationTypes -ErrorAction Stop + $script:uiaReady = $true + } + $list = Get-AppConversationList $hwnd $knownTitles + if (-not $list) { Dbg 'app conversation list not found'; return $null } + $cond = New-Object System.Windows.Automation.AndCondition( + (New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::NameProperty, $name)), + (New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::ControlTypeProperty, + [System.Windows.Automation.ControlType]::Button))) + $hits = $list.FindAll([System.Windows.Automation.TreeScope]::Descendants, $cond) + for ($i = 0; $i -lt $hits.Count; $i++) { + try { + $el = $hits.Item($i) + # 只认能 Invoke 的:项目分组那种按钮不支持 Invoke,点了也没用。 + $el.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern) | Out-Null + return $el + } catch { continue } + } + } catch { + Dbg ("APP CONVERSATION LOOKUP FAIL: " + $_.Exception.Message) + } + return $null +} + +# 切到桌面端 app 里的那个会话。 +# +# app 的侧边栏把每个会话渲染成一个 Button,可访问性名就是会话标题; +# UIA 的 Invoke 就是一次真实点击,app 就切到那个会话。这和切 Windows +# Terminal 标签页是同一招(那边用 SelectionItemPattern.Select)。 +# +# 为什么不用 deep link:app 只注册了 minimax-cn:// 协议,而且只认一个 +# action —— navigate(整页跳 URL)。它自己那 275 条路由全是 +# /minimax-desktop/api/v1/... 的 API,没有"某个会话"的 URL。所以没有 +# 官方入口能定位会话,只能点它的侧边栏。 +# +# 匹配用标题,和终端那边一样有"同名会话点错第一个"的风险;找不到就返回 +# false,让调用方走自己的兜底(桌面端是聚焦 app 窗口,不是新开终端)。 +function Switch-ToAppConversation([string[]]$names) { + if (-not $names -or $names.Count -eq 0) { return $false } + $app = Find-DesktopAppWindow + if (-not $app) { return $false } + # 已知标题全集:侧边栏的会话列表容器靠"装着至少两个已知标题"来认。 + # 只用这一个会话的标题的话,列表里只有一个会话时认不出容器。 + $known = @() + try { + foreach ($s in @(Read-SessionsFile)) { + if ($s.title) { $known += [string]$s.title } + foreach ($x in @($s.titles)) { if ($x) { $known += [string]$x } } + } + } catch {} + $known = @($known | Select-Object -Unique) + if ($known.Count -lt 2) { return $false } + # 名字候选有上限:每个候选一次 UIA 查询(实测 47ms),历史名字太多会把 + # 点击处理拖长,而最近的那几个名字才是对得上的。 + $tried = 0 + foreach ($n in $names) { + if ($tried -ge 4) { break } + $nn = ([string]$n).Trim() + if (-not $nn) { continue } + $tried++ + $el = Get-AppConversationButton ([IntPtr]$app.Hwnd) $nn $known + if (-not $el) { continue } + try { + $el.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() + Dbg ("app conversation switched: " + $nn) + return $true + } catch { + Dbg ("APP CONVERSATION INVOKE FAIL: " + $_.Exception.Message) + return $false + } + } + return $false +} + +# 把桌面端 app 的窗口带到前台。 +function Activate-AppWindow { + $app = Find-DesktopAppWindow + if (-not $app) { return $false } + Focus-Window -Target @{ Hwnd = $app.Hwnd; Pid = $app.Pid; Exe = $app.Exe } + return $true +} + +# 当前前台窗口是哪个进程。 +# +# 切了 tab 但没抢到前台,肉眼看不出来,只能问系统。 +# +# 记 pid 而不是标题:Windows Terminal 的前台窗口是它的 XAML 子窗口, +# 标题是空的,pid 才是 WindowsTerminal.exe。 +function Get-ForegroundPid { + try { + $fg = [WinAPI]::GetForegroundWindow() + if ($fg -eq [IntPtr]::Zero) { return 0 } + # 变量名不能叫 $pid:PowerShell 的变量名不区分大小写,$PID 是只读的 + # 自动变量,赋值直接抛 "Cannot overwrite variable PID"。而这里外面还 + # 包着 catch —— 异常被吞掉,表现是"这个函数永远返回 0",从外面看 + # 一点异常都看不出来。同名的坑 $HOME 也踩过一次。 + $procId = [uint32]0 + [void][WinAPI]::GetWindowThreadProcessId($fg, [ref]$procId) + return [int]$procId + } catch { return 0 } +} + +# 前台窗口现在是不是目标进程。 +# +# SetForegroundWindow 失败是**静默**的:返回值不告诉你成没成。所以每次抢完 +# 前台都要回读一次前台窗口的 pid 跟目标 pid 对一下 —— 对不上就是没抢到。 +# 不回读的话,日志里一句 "switched" 会把"没抢到"盖过去。 +function Test-ForegroundPid([uint32]$procId) { + $fg = Get-ForegroundPid + if ($fg -eq [int]$procId) { return $true } + Dbg ("FOCUS CHECK: want pid=" + $procId + " foregroundPid=" + $fg) + return $false +} + +# 把终端窗口抢到前台。 +# +# 主 pill 的"显示/隐藏终端"早就解决过这个问题,用的是既有那套聚焦:先 +# TOPMOST 再 NOTOPMOST(这样 BringWindowToTop 才推得动),然后 +# SetForegroundWindow。直接复用,不另造一套。 +# +# 裸的 SetForegroundWindow 不够:调用方是后台进程时,Windows 的前台锁会 +# 挡下来 —— 窗口切了 tab,用户却看不到变化。 +function Focus-TerminalWindow([IntPtr]$h) { + if (-not $h -or $h -eq [IntPtr]::Zero) { return $false } + try { + # 变量名不能叫 $pid —— $PID 是只读自动变量,赋值会抛异常,被这里的 + # catch 吞掉,于是"抢前台"这步从来没发生过,而外面看着一切正常。 + $procId = [uint32]0 + [void][WinAPI]::GetWindowThreadProcessId($h, [ref]$procId) + # 先把终端撑成满屏,再抢前台。 + # + # 顺序不能反:Focus-Window 里对最小化窗口走的 SW_RESTORE 用的是"上次 + # 正常尺寸",而那个尺寸可能已经被写坏(0x0 -> WT 的 min-size 兜底 -> + # 480x84 的一条,还被挪到了 (0,0))。先撑满屏,顺手把那个坏尺寸修 + # 回来,用户看到的才是满屏的终端,而不是左上角一条。 + Show-WindowFullScreen $h | Out-Null + Focus-Window -Target @{ Hwnd = $h; Pid = $procId; Exe = 'WindowsTerminal' } + if (Test-ForegroundPid $procId) { + Dbg ("FOCUS CONFIRMED: pid=" + $procId + " hwnd=" + $h) + return $true + } + # 第一套没抢到:换主 pill 真正在用的那套。 + # + # Focus-Window 里两次 SetWindowPos 都带 SWP_NOACTIVATE,只动 z-order + # 不激活;主 pill 的 Toggle-Window 用的是 SetWindowPos(HWND_TOP) 且 + # **不**带 SWP_NOACTIVATE —— 那个会顺手把窗口激活,这才是"到前台" + # 的关键。用同一套,别自创第四种。 + # SWP_NOSIZE / SWP_NOMOVE 同样不能少:少了就是"缩到 0x0 + 挪到 (0,0)"。 + $flags = [WinAPI]::SWP_NOSIZE -bor [WinAPI]::SWP_NOMOVE + [WinAPI]::SetWindowPos($h, [WinAPI]::HWND_TOP, 0, 0, 0, 0, $flags) | Out-Null + [WinAPI]::BringWindowToTop($h) | Out-Null + [WinAPI]::SetForegroundWindow($h) | Out-Null + if (Test-ForegroundPid $procId) { + Dbg ("FOCUS CONFIRMED (z-order): pid=" + $procId + " hwnd=" + $h) + return $true + } + Dbg ("FOCUS NOT CONFIRMED: want pid=" + $procId + " foregroundPid=" + (Get-ForegroundPid)) + return $false + } catch { + Dbg ("FOCUS FAIL: " + $_.Exception.Message) + return $false + } +} + +# 切到已经开着这个会话的终端 tab。 +# +# 为什么需要这一段:mcode 会话跑在某个终端 tab 里。点列表的时候如果新开 +# 一个 "mcode --session ",同一个会话就有两个终端在跑 —— 旧的还在, +# 新的也起来了。用户要的是回到原来那个,不是再要一个。 +# +# Windows Terminal 的 tab 标题是 "<状态> | <会话名> | MCode",会话名是 +# mcode 自己设的(ANSI OSC 0)。Win32 的 SetForegroundWindow 只能激活到 +# 窗口的**当前活动** tab,切不到指定的 tab;UI Automation 能列出 tab +# 并选中它(实测:选中后回读,活动的确实是目标 tab)。 +# +# 匹配用会话的标题历史。tab 上那个名字是 mcode 在**某个时刻**设的,而 +# 会话标题会随着对话推进变(运行时 SQLite 里存的是最新的),所以要拿 +# 历史上出现过的所有名字一起匹配。 +# +# 匹配不上就退回原来的做法(新开一个)。这里不搞更聪明的:终端标题可能 +# 被程序改过,终端也可能根本不是 Windows Terminal(桌面端 app 内嵌的 +# 终端就不是),这时候新开反而是唯一到得了那个会话的办法。 +$script:uiaReady = $false + +function Select-SessionTerminal([string[]]$names) { + if (-not $names -or $names.Count -eq 0) { return $false } + try { + if (-not $script:uiaReady) { + Add-Type -AssemblyName UIAutomationClient -ErrorAction Stop + Add-Type -AssemblyName UIAutomationTypes -ErrorAction Stop + $script:uiaReady = $true + } + if (-not ('IslandNative' -as [type])) { + # 单行字符串,不写 here-string:外层已经是 here-string 了,嵌套会提前结束。 + Add-Type -Namespace IslandNative -Name Win -MemberDefinition '[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);' -ErrorAction Stop + } + $root = [System.Windows.Automation.AutomationElement]::RootElement + $winCond = New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::ClassNameProperty, 'CASCADIA_HOSTING_WINDOW_CLASS') + $wins = $root.FindAll([System.Windows.Automation.TreeScope]::Children, $winCond) + $tabCond = New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::ControlTypeProperty, + [System.Windows.Automation.ControlType]::TabItem) + foreach ($w in $wins) { + $tabs = $w.FindAll([System.Windows.Automation.TreeScope]::Descendants, $tabCond) + for ($t = 0; $t -lt $tabs.Count; $t++) { + $tab = $tabs.Item($t) + $title = [string]$tab.Current.Name + if (-not $title) { continue } + # "<状态> | <会话名> | MCode" -> 取中间那一段 + $parts = @($title -split '\|') + $tabName = '' + if ($parts.Count -ge 3) { $tabName = $parts[1].Trim() } else { $tabName = $title.Trim() } + $hit = $false + foreach ($n in $names) { + if (-not $n) { continue } + $nn = ([string]$n).Trim() + if (-not $nn) { continue } + if ($tabName -eq $nn -or $title.Contains($nn)) { $hit = $true; break } + } + if (-not $hit) { continue } + try { + $sp = $tab.GetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern) + $sp.Select() + } catch { continue } + # 选中 tab 不会把窗口带到前台,而裸的 SetForegroundWindow 又会被 + # Windows 的前台锁挡下(调用方是后台进程)—— 实测窗口切了 tab, + # 用户却看不到任何变化。 + # + # 所以复用主 pill 的聚焦:先 TOPMOST 再 NOTOPMOST,让 + # BringWindowToTop 能把窗口推到最前,最后 SetForegroundWindow。 + # tab 已经切过去了。即使抢前台没成功,也**不能**回退去新开一个终端 + # —— 那正是"同一个会话两个终端"的病根。抢前台失败只记日志。 + if (-not (Focus-TerminalWindow ([IntPtr]$w.Current.NativeWindowHandle))) { + Dbg ("session tab selected but foreground not confirmed: " + ($names -join ' | ')) + } + return $true + } + } + } catch { + # UI Automation 不可用、终端不是 Windows Terminal、标题对不上…… + # 统统退回原来的做法。 + } + return $false +} + +# 找不到具体 tab 时,至少把终端窗口带到前台,用户自己找那个标签。 +# +# 关键是**不要再开一个**:同一个会话两个终端在跑,正是这个功能要消除的 +# 问题。会话名对不上(终端标题是 mcode 某个时刻起的名字,而会话标题会 +# 变)只影响"切到哪个标签",不该影响"会不会多出一个终端"。 +function Activate-TerminalWindow { + try { + if (-not $script:uiaReady) { + Add-Type -AssemblyName UIAutomationClient -ErrorAction Stop + Add-Type -AssemblyName UIAutomationTypes -ErrorAction Stop + $script:uiaReady = $true + } + if (-not ('IslandNative' -as [type])) { + Add-Type -Namespace IslandNative -Name Win -MemberDefinition '[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);' -ErrorAction Stop + } + $root = [System.Windows.Automation.AutomationElement]::RootElement + $winCond = New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::ClassNameProperty, 'CASCADIA_HOSTING_WINDOW_CLASS') + $tabCond = New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::ControlTypeProperty, + [System.Windows.Automation.ControlType]::TabItem) + foreach ($w in $root.FindAll([System.Windows.Automation.TreeScope]::Children, $winCond)) { + # 优先挑开着 mcode 的那个窗口(tab 标题是 "<状态> | <会话名> | MCode") + $hasMcode = $false + $tabs = $w.FindAll([System.Windows.Automation.TreeScope]::Descendants, $tabCond) + for ($i = 0; $i -lt $tabs.Count; $i++) { + if ([string]$tabs.Item($i).Current.Name -match '\|\s*MCode\s*$') { $hasMcode = $true; break } + } + if (-not $hasMcode) { continue } + try { + # 找到终端窗口就算"找到了"。抢前台没成功**不能**返回 $false —— + # 调用方拿 $false 会新开一个终端,同一个会话就多出一个终端了。 + Focus-TerminalWindow ([IntPtr]$w.Current.NativeWindowHandle) | Out-Null + return $true + } catch {} + } + } catch { + } + return $false +} + +# 这里**曾经**有一个 Start-SessionTerminal:找不到目标就开一个 +# "mcode --session " 的新标签。 +# +# 它被删掉了,原因有两个,都是 review 点出来的: +# 1. 它把会话 id 拼进一条命令字符串交给 `powershell -Command`。id 来自 +# 会话目录名里的 base64 和 sessions.json,两者都不可信,所以一个形如 +# `x; Set-Content ...` 的 id 能执行任意 PowerShell。不做"更小心的引号 +# 拼接",直接不留这条把数据变成代码的路。 +# 2. 它和这个功能的承诺相矛盾:点一行是为了**回到**那个会话,不是再起 +# 一个。同一个会话两个终端在跑,正是要消除的问题。 +# +# 现在的行为是失败关闭:匹配不到就只写日志,不动用户的桌面。 +# smoke.mjs 里有一条检查盯着这里不许再长出命令拼接。 + +# 工具家族配色:working 态下用 family 色盖掉 state 色,一眼分辨"在读"和"在改"。 +# 只覆盖 dot/ring(视觉识别),不动 stateText 文案。 +# family 名与 detector 的 $TOOL_FAMILIES 值一一对应;缺 family 时回落到 state 色。 +# +# 色相分布刻意拉开:绿(shell) / 蓝(read) / 黄(write) / 紫(search) / 青(task) / +# 粉(web) / 灰(plan)。同样是 Apple 暗色系统色,两两之间用 CIE76 dE 验过, +# 最小的一对也远大于 20("人眼看得出是不同颜色"的门槛)—— 早先 plan 用 +# 灰紫、和 search 只差一点点,在深色玻璃上基本分不开。 +$familyMap = @{ + shell = '#FF30D158' # bash / shell → 绿 + read = '#FF0A84FF' # read → 蓝 + write = '#FFFFD60A' # edit / write → 黄 + search = '#FFBF5AF2' # grep / glob → 紫 + task = '#FF40C8E0' # task → 青 + web = '#FFFF375F' # web_search/fetch → 粉 + plan = '#FF8E8E93' # todowrite → 灰 } # 颜色转 brush @@ -370,6 +2593,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 +2628,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 +2645,39 @@ 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 + # 状态色表跟当前极走:深色极用 Apple 暗色系统色,浅色极用浅色系统色。 + $smap = Get-GlassStateMap + $fmap = Get-GlassFamilyMap + $s = $smap[$State] + if (!$s) { $s = $smap['idle'] } + # 家族色只作用于 active 态(thinking/working/waiting),这样 done 绿 / error 红 + # 依然是"结果"语义,家族色只用来区分"在做什么"。 + $dotHex = $s.dot + $ringHex = $s.ring + if ($Family -and $State -in @('thinking','working','waiting')) { + $famHex = $fmap[$Family] + if ($famHex) { $dotHex = $famHex; $ringHex = $famHex } + } + $script:statusDot.Fill = C $dotHex + $script:pulseRing.Fill = C $ringHex + # 发光跟着点的颜色走:在深色玻璃上,一个亮着的点会把它周围照亮。 + # 只在"在跑"的状态点光 —— done 绿 / error 红是结果色,不该发光晕, + # 发光会被读成"还在动"。 + if ($State -in @('thinking','working','waiting')) { + # Effect.Color 要的是 Color,不是 Brush —— C() 返回的是 Brush,不能直接用。 + $script:statusDotGlow.Color = [System.Windows.Media.ColorConverter]::ConvertFromString($dotHex) + $script:statusDotGlow.Opacity = 0.85 + } else { + $script:statusDotGlow.Opacity = 0.0 + } $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 } @@ -407,13 +2691,14 @@ function Update-State { if ($hasTime) { $parts += (Format-ResetMs $Usage5hResetMs) } if ($hasPct) { $parts += ("{0}%" -f [int]$Usage5h) } $script:usage5hText.Text = $parts -join ' ' - $col = if ($Usage5h -ge 50) { '#FF6B7280' } # 剩 >= 50% 灰 - elseif ($Usage5h -ge 20) { '#FFEAB308' } # 剩 20-50% 黄 - else { '#FFEF4444' } # 剩 < 20% 红 + $ink = Get-GlassInk + $col = if ($Usage5h -ge 50) { $ink.Dim } # 剩 >= 50% 灰 + elseif ($Usage5h -ge 20) { $ink.Warn } # 剩 20-50% 黄 + else { $ink.Danger } # 剩 < 20% 红 $script:usage5hText.Foreground = C $col } else { $script:usage5hText.Text = '' - $script:usage5hText.Foreground = C '#FF6B7280' + $script:usage5hText.Foreground = C (Get-GlassInk).Dim } # Elapsed timer:进入 active 启动/跨 state 重置,退出 active 停表并清空文字 @@ -447,20 +2732,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 +2762,366 @@ 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 "FOCUS: re-resolved to hwnd $hwnd" + 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 { - # 1) 授权目标进程可以切前台(modern Windows 强制) - [WinAPI]::AllowSetForegroundWindow([uint32]$targetPid) | Out-Null - # 2) 最小化就还原 - if ([WinAPI]::IsIconic($hwnd)) { - [WinAPI]::ShowWindow($hwnd, 9) | Out-Null # SW_RESTORE + $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 } } - # 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) + } + # 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 +} + +# 把窗口撑到所在显示器的 work area(满屏)。 +# +# 为什么不能只靠 SW_RESTORE 还原:还原用的是"上次正常尺寸",而那个尺寸可能 +# 已经被写坏。历史上 Focus-Window 用 cx=0/cy=0 的 SetWindowPos 把它缩成了 +# 0x0,Windows Terminal 的 min-size 兜底把窗口变成 480x84 的一条,位置还 +# 被挪到了 (0,0)。只还原的话,出来的是那条,不是满屏。 +# +# 所以这里三步走:先还原到普通状态,再按 work area 显式设尺寸(这一步顺手 +# 把那个坏掉的"还原尺寸"也修回来),最后再最大化。 +function Show-WindowFullScreen([IntPtr]$h) { + try { + $wa = [WinAPI]::GetWorkAreaForWindow($h) + if ($wa.Left -eq -1) { return $null } + $cx = $wa.Right - $wa.Left + $cy = $wa.Bottom - $wa.Top + # 三步,顺序不能乱: + # 1) SW_RESTORE 先退出最大化 / 最小化,回到"普通"状态 + # 2) SetWindowPos 按 work area 显式设大小 —— 这一步发生在普通状态下, + # 所以窗口的"还原尺寸"被真正写成 work area + # 3) SW_MAXIMIZE 再最大化 + # + # 为什么第 2 步不能省:对**已经最大化**的窗口调 SetWindowPos,Windows + # 不会更新它的还原尺寸,那个坏尺寸(历史上被 0x0 缩成 480x84 的)就 + # 一直留着 —— 以后任何一次还原(用户点还原按钮、WT 自己恢复)都会把 + # 那条小条请回来。 + # + # 为什么不用 SetWindowPlacement:实测对 WT 它不生效,rcNormalPosition + # 写进去,读出来还是 0x0。 + [WinAPI]::ShowWindow($h, 9) | Out-Null # SW_RESTORE + [WinAPI]::SetWindowPos($h, [IntPtr]::Zero, $wa.Left, $wa.Top, $cx, $cy, [WinAPI]::SWP_NOZORDER) | Out-Null + [WinAPI]::ShowWindow($h, 3) | Out-Null # SW_MAXIMIZE + return @{ Left = $wa.Left; Top = $wa.Top; W = $cx; H = $cy } + } catch { + return $null + } +} + +# Raise a window to the front: show + focus, whatever its current visible +# state is. After the desktop-app adaptation both focus and toggle take a +# resolved target object; the caller decides which one it is. +function Focus-Window { + param([Parameter(Mandatory = $true)]$Target) + + try { + [WinAPI]::AllowSetForegroundWindow([uint32]$Target.Pid) | Out-Null + # 只在窗口真的"看不见"时才动它的显示状态: + # minimized -> SW_RESTORE(还原) + # hidden -> SW_SHOW(显示) + # 已经可见的窗口一个字节都不碰 —— 对一个已经最大化的窗口再调一次 + # SW_SHOW 没有意义,只是白白多一次状态抖动。 + if ([WinAPI]::IsIconic($Target.Hwnd)) { + [WinAPI]::ShowWindow($Target.Hwnd, 9) | Out-Null # SW_RESTORE + } elseif (-not [WinAPI]::IsWindowVisible($Target.Hwnd)) { + [WinAPI]::ShowWindow($Target.Hwnd, 5) | Out-Null # SW_SHOW + } + # TOPMOST -> NOTOPMOST: it lets BringWindowToTop push the window all the + # way up without leaving a "always on top" side effect behind. + # + # SWP_NOSIZE / SWP_NOMOVE 是必须的,不是锦上添花:这里 cx=0/cy=0/ + # X=0/Y=0 的意思是"大小和位置都不变",但这个意思**只在 flag 齐全时 + # 才成立**。少了 SWP_NOSIZE,Windows 会把它当成"resize 到 0x0"—— + # Windows Terminal 有 min-size 兜底,于是窗口塌成 480x84 的一条; + # 少了 SWP_NOMOVE 同理,窗口会被真的挪到 (0,0) 左上角。 + # + # 同一文件里 Toggle-Window 的注释早就写明了这个坑,但 Focus-Window + # 一直漏着,直到会话点击真的走到这里才暴露出来。 + $nofollow = [WinAPI]::SWP_NOACTIVATE -bor [WinAPI]::SWP_NOSIZE -bor [WinAPI]::SWP_NOMOVE + [WinAPI]::SetWindowPos($Target.Hwnd, [WinAPI]::HWND_TOPMOST, 0, 0, 0, 0, $nofollow) | Out-Null + [WinAPI]::SetWindowPos($Target.Hwnd, [IntPtr]::new(-2), 0, 0, 0, 0, $nofollow) | Out-Null # HWND_NOTOPMOST + [WinAPI]::BringWindowToTop($Target.Hwnd) | Out-Null + [WinAPI]::SetForegroundWindow($Target.Hwnd) | Out-Null + # "DONE" 而不是 "OK":这里只说明这几个调用没抛异常,不代表窗口真到了 + # 前台。到底到没到,看 Test-ForegroundPid 的回读。 + Dbg ("FOCUS DONE: 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)+2) SW_MAXIMIZE 激活+标记 maximized,再用 SetWindowPos 强制拉到 + # monitor work area(绕过 DPI/remembered-size 限制;细节见 + # Show-WindowFullScreen 的注释) + $wa = Show-WindowFullScreen $Target.Hwnd + if ($wa) { + # The -f has to sit inside the parentheses: written as + # `Dbg "..." -f a, b` the format operator binds to the command, not to + # the string, and the log line keeps the raw {0} {1} placeholders. + Dbg ("TOGGLE: forced to work area ({0},{1}) {2}x{3}" -f $wa.Left, $wa.Top, $wa.W, $wa.H) + } + # 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 +3140,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 +3153,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,17 +3176,78 @@ 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)" + } +} + # 窗口定位 +# +# 窗口比药丸大一圈,这一圈留白是给投影的。 +# +# 之前窗口跟药丸一样大,DropShadowEffect 画出来的投影全被窗口边界裁掉 +# 了 —— 药丸看着像"贴在屏幕上",而不是"浮在上面"。苹果那套材质最吃 +# 的就是这口投影。 +# +# 留白是透明的,鼠标照常穿过去(layered 窗口按像素 alpha 命中)。 +# 位置语义不变:cfg.x/cfg.y 记的仍然是**药丸**的位置,窗口只是往左上 +# 挪了半个留白。旧配置不用迁移。 $screen = [System.Windows.SystemParameters]::WorkArea if ($cfg.x -lt 0) { - $window.Left = [Math]::Floor(($screen.Width - $cfg.width) / 2) + $screen.Left - $window.Top = $screen.Top + 14 + $window.Left = [Math]::Floor(($screen.Width - $cfg.width) / 2) + $screen.Left - $script:glassMargin + $window.Top = $screen.Top + 14 - $script:glassMargin } else { - $window.Left = $cfg.x - $window.Top = $cfg.y + $window.Left = $cfg.x - $script:glassMargin + $window.Top = $cfg.y - $script:glassMargin } -$window.Width = $cfg.width -$window.Height = $cfg.height +$window.Width = $cfg.width + 2 * $script:glassMargin +$window.Height = $cfg.height + 2 * $script:glassMargin +# 药丸高度锚点。row 0 锁死成"药丸 + 上下留白",所以展开 session 列表时 +# 窗口变高、药丸本身一动不动,列表在它下面长出来。 +$script:pillHeight = $cfg.height +try { + $pillRow = $window.FindName('PillRow') + if ($pillRow) { $pillRow.Height = New-Object System.Windows.GridLength($script:pillHeight + 2 * $script:glassMargin) } +} catch {} $window.Opacity = $cfg.opacity # 右键被禁用(用 CLI 'mcode-island pin' 替代,更稳) @@ -629,39 +3257,166 @@ $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) + # 列表没开、而鼠标就在药丸上:补开一次。 + # + # MouseEnter 只在"鼠标进入窗口"时触发。widget 恰好在光标底下启动的话 + # (启动后光标没动过),MouseEnter 永远不会来,列表就再也不会出现。 + # 拖拽中不弹(dragStart 非空)。 + if ($null -eq $script:dragStart -and $script:sessionRowsPanel -and + $script:sessionRowsPanel.Visibility -ne [System.Windows.Visibility]::Visible) { + # 补开的那条路也要记前台:MouseEnter 可能从来没来过(比如 widget 在 + # 光标底下启动),这时候只有这一条路能记住用户原来在哪个窗口。 + Save-ForegroundBeforeAction + try { Show-SessionList } catch { Dbg "SESSION LIST SHOW FAIL: $($_.Exception.Message)" } + } + if ($null -eq $script:dragStart) { return } + # The position is read EVERY event, not only before the threshold is + # crossed: the window is moving, so a position read during an earlier + # 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)" + } + # 拖动时玻璃也得跟着走:底下那块屏幕在变,不重抓的话,药丸里那团模糊 + # 是"拖之前那块屏幕",一眼就看出是贴图。 + # 抓屏 + 模糊要十几毫秒,不能每个 MouseMove 都做 —— 250ms 一次够了, + # 拖动过程中本来也看不清细节。 + try { + $now2 = [DateTime]::UtcNow + if (($now2 - $script:glassDragStamp).TotalMilliseconds -ge 250) { + $script:glassDragStamp = $now2 + [void](Update-GlassBackdrop) + } + } catch {} }) $window.Add_MouseLeftButtonUp({ + try { $window.ReleaseMouseCapture() } catch {} + $wasDrag = $script:didDrag try { if ($script:dragStart -and -not $script:didDrag) { Dbg 'CLICK detected' Flash-Click - Focus-CallerWindow + Invoke-ClickTarget } } catch { Dbg "CLICK FAIL: $($_.Exception.Message)" } + # 拖完松手:位置变了,玻璃重抓一次(拖动过程中的节流刷新可能还差几十 + # 毫秒没跟上)。 + if ($wasDrag) { + try { [void](Update-GlassBackdrop) } catch {} + } $script:dragStart = $null $script:didDrag = $false }) +# 多 session 列表的展开/收起。 +# +# 鼠标停在 pill 上 -> 展开;离开 -> 宽限期后收起。列表是独立窗口,鼠标从 +# pill 走到列表会经过一段"两个窗口都不在"的地带,所以要留宽限期,鼠标 +# 进列表窗口就取消。 +$window.Add_MouseEnter({ + if ($script:listHideTimer) { try { $script:listHideTimer.Stop() } catch {} ; $script:listHideTimer = $null } + # 鼠标进来的这一刻,焦点还在用户原来的窗口上 —— 这是记下"点 × 之后该 + # 还给谁"的唯一时机。点下去就晚了:那时焦点已经在灵动岛自己身上。 + Save-ForegroundBeforeAction + try { Show-SessionList } catch { Dbg "SESSION LIST SHOW FAIL: $($_.Exception.Message)" } +}) +$window.Add_MouseLeave({ + Schedule-SessionListHide +}) + + +# 第一次渲染之后:把材质几何建起来,并抓第一张背景。 +# +# ContentRendered 而不是 Loaded:Loaded 时布局还没算完, +# ActualWidth/ActualHeight 还不是最终值,材质路径会按错的尺寸建。 +# 第一帧玻璃还没抓到(抓屏要十几毫秒),所以 XAML 里给了一层保底底色, +# 不然会闪一下"什么都没有"。 +$window.Add_ContentRendered({ + try { + Sync-GlassGeometry + [void](Apply-GlassMaterial 'Pill' 0.25 $false) + [void](Update-GlassBackdrop) + Dbg 'glass material initialized' + } catch { + Dbg "GLASS INIT FAIL: $($_.Exception.Message)" + } +}) # FileSystemWatcher 监听 status.json(轮询版本,FSW 在 WPF 应用里经常抽风) $script:lastStatusMtime = $null @@ -670,13 +3425,26 @@ $showSignalFile = Join-Path $configDir 'show.signal' $timer = New-Object System.Windows.Threading.DispatcherTimer $timer.Interval = [TimeSpan]::FromMilliseconds(400) $timer.Add_Tick({ - # 1) 处理 "show" 信号("mcode-island show" 创建这个文件) + # 0) 自愈:被别的窗口压住就重新置顶(内部 1.5s 节流) + Assert-Topmost + # 1) 玻璃跟着背后的内容走。Liquid Glass 不是一张静态贴图:内容在下面 + # 滚动,玻璃的模糊和明度就该跟着变(WWDC25 明确讲了这个自适应)。 + # 放在所有 return 之前,不然 status.json 没变化的那一轮就永远刷不到。 + try { + if ($window.Visibility -eq [System.Windows.Visibility]::Visible -and + ([DateTime]::UtcNow - $script:glassLastUpdate).TotalMilliseconds -ge $script:glassRefreshMs) { + Sync-GlassGeometry + [void](Update-GlassBackdrop) + } + } catch {} + # 2) 处理 "show" 信号("mcode-island show" 创建这个文件) if (Test-Path $showSignalFile) { try { Remove-Item $showSignalFile -Force -ErrorAction SilentlyContinue } catch {} $window.Dispatcher.Invoke([Action]{ $window.Show() $window.WindowState = [System.Windows.WindowState]::Normal $window.Activate() + Assert-Topmost -Force Dbg 'SHOWN via signal file' }) } @@ -689,18 +3457,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 +3495,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 '' @@ -735,10 +3521,13 @@ $script:realQuit = $false $window.Add_Closing({ $args[1].Cancel = $true Dbg 'Closing event -> hide' + # pill 藏起来时列表也得跟着藏,否则鼠标一移开就剩一个空框在屏幕上。 + try { Hide-SessionList } catch {} try { $cur = Get-Content $script:configFile -Raw -Encoding UTF8 | ConvertFrom-Json - $cur.x = $window.Left - $cur.y = $window.Top + # 记的是**药丸**的位置:窗口往左上偏了半个留白,加回去。 + $cur.x = $window.Left + $script:glassMargin + $cur.y = $window.Top + $script:glassMargin $cur | ConvertTo-Json | Out-File -Path $script:configFile -Encoding UTF8 } catch {} $window.Hide() diff --git a/plugins/antianqi/mcode-island/mcode-status-detect.ps1 b/plugins/antianqi/mcode-island/mcode-status-detect.ps1 index e0068f0c..1df20cfc 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,143 @@ 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" +# Safe traversal of the sessions root (round-24 review, hetaoBackend). +# SearchOption.AllDirectories follows reparse points, so a junction under the +# sessions root could redirect the walk outside it and hand the detector a log +# that does not belong to this user. This lib replaces the blind recursion +# with a traversal that refuses reparse points and checks canonical +# containment before a candidate is opened. +. "$PSScriptRoot/scripts/lib/Safe-Traversal.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" +# 多 session 跟踪(v0.5.0)。 +# +# 以前 pill 只显示一个 session:Get-LatestSessionFile 取最近被改的那个日志, +# 所以"谁最后动了日志就显示谁",后台会话能把前台的 pill 抢走。这个 lib +# 维护一份有生命周期的 session 列表(运行中 / 待回复 / 点掉消除),写到 +# sessions.json —— 不是 status.json,因为那个文件 agent 侧会整份覆盖。 +# +# $script: 在 dot-source 之后就是本脚本的作用域,所以 lib 里的状态变量和 +# 这里的状态变量是同一份。 +. "$PSScriptRoot/scripts/lib/Session-Tracker.ps1" $script:plan5hLastCallAt = [DateTime]::MinValue $script:plan5hRemainingPct = $null # 0..100,剩余百分比(不再是已用!) $script:plan5hResetMs = $null # 距下次刷新的毫秒数 @@ -141,120 +204,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 { @@ -265,7 +325,10 @@ function Get-LatestSessionFile { $best = $null $bestMtime = [DateTime]::MinValue $bestIsLedger = $false - foreach ($f in [System.IO.Directory]::EnumerateFiles($sessionsRoot, '*.jsonl', 'AllDirectories')) { + # Round-24 review: no blind AllDirectories recursion (it follows reparse + # points). Get-SafeSessionEntries refuses reparse-point entries and + # verifies canonical containment before anything is opened. + foreach ($f in (Get-SafeSessionEntries -Root $sessionsRoot -Pattern '*.jsonl' -Files)) { $name = [System.IO.Path]::GetFileName($f) $isLedger = ($name -eq $FNAME_LEDGER) $isMsgs = ($name -eq $FNAME_MESSAGES) @@ -288,43 +351,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 +607,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 +658,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 +682,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 +697,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 +874,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) { @@ -503,43 +886,78 @@ if (-not $Once) { Log-Line "detector started (PID $PID) mcodeRoot=$mcodeRoot" } +# 多 session 列表的恢复与补录。 +# +# detector 经常重启(per-tick guard、autostart、崩溃)。如果列表只在内存里, +# 重启就把用户手上那些"待回复"清零了 —— 那个信号恰好是最不能丢的。 +# 所以列表存在 sessions.json,启动时读回来。 +# +# 补录是唯一一处还看时间的地方:把最近 30 分钟内还有动静的 session 收进来。 +# 运行期完全靠状态机,不看时间。 +try { + Restore-SessionTracker + Seed-SessionTracker + Write-SessionsFile -Force +} catch { + Log-Line "SESSIONS INIT FAIL: $($_.Exception.Message)" +} + try { while ($true) { + # --- per-tick guard ------------------------------------------------------- + # 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 - } else { - $mcodePid = Get-McodePid - $script:lastMcodePid = $mcodePid - $script:lastMcodePidAt = $now + # 0) 多 session 列表:状态机 + 写盘 + # + # 放在最前面:它决定"哪些 session 有活动"。主线状态推断读的是"最近 + # 被改的那个日志",两者不是一回事——后台会话在跑时,主线应该还是你 + # 前台那个会话的状态。 + try { + Update-SessionTracker + Write-SessionsFile + } catch { + Log-Line "SESSIONS UPDATE FAIL: $($_.Exception.Message)" } - if (-not $mcodePid) { - $inferred = @{ state=$S_ERROR; message=$MSG_MCODE_EXIT } + + # 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 { - # 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 - } + $latestFile = Get-LatestSessionFile + $script:lastLatestFile = $latestFile + $script:lastLatestFileAt = $now + } + 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 +967,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 +1051,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 +1062,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..36575c0c 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.5.0", + "description": "Windows 桌面灵动岛 (Dynamic Island) 状态窗口:让 mcode agent 把工作状态(idle/thinking/working/waiting/done/error)实时推送到屏幕顶部悬浮 pill,agent 自己忙的时候用户不用切回 mcode 也能看到进度。v0.5.0 增加多会话列表:鼠标停在 pill 上会展开当前所有在跑的会话,每行显示会话标题(取自会话日志里的首条提问,脱敏并截断)、最后一条消息、相对时间和状态词;点一行会切回那个会话所在的 Windows Terminal 标签并把终端带到前台,不会为同一个会话再开一个终端;每行右侧的 × 只把这一行从列表里去掉,不切会话。列表有界:数据侧最多 20 个会话,widget 最多画 12 行,sessions.json 本身不超过 64 KB,超出的按最后活动时间淘汰最旧的。日志扫描有边界:不跟随 junction/符号链接,每次打开前校验规范化路径仍在 sessions 根目录内,不在就跳过。展开面板的高度不会越开越长。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/Safe-Traversal.ps1 b/plugins/antianqi/mcode-island/scripts/lib/Safe-Traversal.ps1 new file mode 100644 index 00000000..c1dc855f --- /dev/null +++ b/plugins/antianqi/mcode-island/scripts/lib/Safe-Traversal.ps1 @@ -0,0 +1,126 @@ +# --------------------------------------------------------------------------- +# Safe traversal of the sessions root (round-24 review, hetaoBackend). +# +# 为什么需要它 +# +# 以前的两个 scanner 都用同一种盲递归: +# +# [System.IO.Directory]::EnumerateDirectories($root, '*session_*', 'AllDirectories') +# [System.IO.Directory]::EnumerateFiles($sessionsRoot, '*.jsonl', 'AllDirectories') +# +# 微软的文档写得很清楚:SearchOption.AllDirectories **包含 reparse point** +# (符号链接、junction、挂载点): +# https://learn.microsoft.com/en-us/dotnet/api/system.io.searchoption?view=net-10.0 +# +# 也就是说,sessions 目录底下只要有一个 junction,递归就会顺着它走进 +# sessions root 之外:detector 会读到外面那个目录里的 jsonl,会话列表会把 +# 别人的会话内容显示在灵动岛上。这是路径逃逸,不是显示问题。 +# +# 这里怎么堵 +# +# 1. 不用 AllDirectories。改成自己逐层下钻,每一层只枚举当前层 +# (SearchOption.TopDirectoryOnly)。 +# 2. 每进入一个目录之前先看它是不是 reparse point —— 是就不进,也不下钻。 +# 逐层下钻的好处是:祖先天然被检查过了(root 到候选路径上的每一级 +# 都过了一遍),不需要另写一套祖先检查。 +# 3. 每个候选在"打开之前"都要过一遍 containment:它的规范化路径必须 +# 仍在规范化 root 之下,且不等于 root 本身。 +# 4. 文件本身也查 reparse point —— 一个 jsonl 可以直接是个符号链接。 +# 5. 深度上限。junction 可以套 junction,没有上限就会一直往下走。 +# +# root 自身是不是 reparse point 不查:那是用户自己挑的目录,他要是把 +# sessions 目录做成 junction,那是他的配置,不是逃逸。检查的是 root +# **底下**的那条链。 +# --------------------------------------------------------------------------- + +# 这个路径(或它指向的东西)是不是 reparse point。 +# +# 读不到属性时返回 true:拿不准就当危险,不放行。fail closed。 +function Test-PathIsReparsePoint([string]$Path) { + if (-not $Path) { return $true } + try { + $attr = [System.IO.File]::GetAttributes($Path) + return (($attr -band [System.IO.FileAttributes]::ReparsePoint) -eq [System.IO.FileAttributes]::ReparsePoint) + } catch { + return $true + } +} + +# $Candidate 规范化之后,是不是还在 $RootFull 底下。 +# +# 前缀比较必须带路径分隔符,否则 C:sessions-evil 会被当成 C:sessions 的 +# 子目录。等于 root 本身也不算"在底下"。 +function Test-PathContained([string]$RootFull, [string]$Candidate) { + if (-not $RootFull -or -not $Candidate) { return $false } + try { + $c = [System.IO.Path]::GetFullPath($Candidate) + $r = [System.IO.Path]::GetFullPath($RootFull) + } catch { + return $false + } + if ($c -eq $r) { return $false } + $prefix = $r.TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) + [System.IO.Path]::DirectorySeparatorChar + return $c.StartsWith($prefix, [System.StringComparison]::OrdinalIgnoreCase) +} + +# 在 $Root 下安全地找 $Pattern 匹配的东西。 +# +# -Files 找文件(默认找目录) +# -Pattern 每一层都用同一个 pattern 过滤,和 EnumerateXxx(root, pat, +# AllDirectories) 的语义一致:pattern 在每一层都生效。 +# -MaxDepth 最多下钻几层。 +# +# 返回一个数组(可能为空)。每个元素都过了两道检查:路径链上没有 +# reparse point,且规范化之后仍在 root 之下。 +function Get-SafeSessionEntries { + param( + [string]$Root, + [string]$Pattern = '*', + [switch]$Files, + [int]$MaxDepth = 16 + ) + $out = New-Object System.Collections.ArrayList + if (-not $Root) { return $out } + try { $rootFull = [System.IO.Path]::GetFullPath($Root) } catch { return $out } + if (-not (Test-Path -LiteralPath $rootFull)) { return $out } + + $queue = New-Object System.Collections.Queue + $queue.Enqueue([pscustomobject]@{ Path = $rootFull; Depth = 0 }) + + while ($queue.Count -gt 0) { + $cur = $queue.Dequeue() + if ($cur.Depth -gt $MaxDepth) { continue } + $dir = $cur.Path + + if ($Files) { + try { + foreach ($f in [System.IO.Directory]::EnumerateFiles($dir, $Pattern, [System.IO.SearchOption]::TopDirectoryOnly)) { + if (Test-PathIsReparsePoint $f) { continue } + if (-not (Test-PathContained $rootFull $f)) { continue } + [void]$out.Add($f) + } + } catch { } + } + + try { + # 这里枚举的是**全部**子目录,不是匹配 pattern 的那些。 + # + # pattern 只决定"哪些算结果",不决定"能不能继续往下走"。 + # EnumerateDirectories(root, '*session_*', AllDirectories) 也会 + # 走遍那些名字不匹配的目录(sessions 树是 ///..., + # 每一层都不叫 session_),只是不把它们当结果返回。按 pattern + # 过滤后再枚举会把下钻也一起挡掉,整棵树就只剩根目录一层。 + foreach ($d in [System.IO.Directory]::EnumerateDirectories($dir, '*', [System.IO.SearchOption]::TopDirectoryOnly)) { + if (-not (Test-PathContained $rootFull $d)) { continue } + # reparse point:不收,也不下钻。 + if (Test-PathIsReparsePoint $d) { continue } + if (-not $Files) { + $leaf = [System.IO.Path]::GetFileName($d) + if ($leaf -like $Pattern) { [void]$out.Add($d) } + } + $queue.Enqueue([pscustomobject]@{ Path = $d; Depth = $cur.Depth + 1 }) + } + } catch { } + } + return $out +} diff --git a/plugins/antianqi/mcode-island/scripts/lib/Session-Tracker.ps1 b/plugins/antianqi/mcode-island/scripts/lib/Session-Tracker.ps1 new file mode 100644 index 00000000..79de816f --- /dev/null +++ b/plugins/antianqi/mcode-island/scripts/lib/Session-Tracker.ps1 @@ -0,0 +1,892 @@ +# --------------------------------------------------------------------------- +# 多 session 跟踪(v0.5.0) +# +# 灵动岛以前只显示一个 session:Get-LatestSessionFile 扫全部 *.jsonl 取最近被 +# 改的那一个。所以 pill 今天的语义是"谁最后动了日志就显示谁",不是"你关心的 +# 那个会话"——后台会话跑一下就能把前台的 pill 抢走。 +# +# 这里维护一份有生命周期的 session 列表: +# +# 检测到活动 -> 运行中 +# agent 停了 -> 待回复(最后一条不是 toolCall / toolResult / thinking) +# 用户点掉 -> 从列表里消除 +# +# 判定"它停了"用的是最后一条消息的类型,不是时间超时:agent 跑一个 40 秒的 +# 工具时日志可能 40 秒没写,但它没停;用超时判定会把"正在跑长工具"误判成 +# "待回复",而且窗口多长都不对。 +# +# 列表写在 sessions.json,不是 status.json:status.json 是 agent 侧 +# (notify-island.ps1)和 detector 两侧都在写的文件,agent 侧是整份覆盖, +# 把列表塞进去会被下一次工具调用整份抹掉。sessions.json 只有 detector 写。 +# --------------------------------------------------------------------------- + +# 被跟踪的 session:id -> 条目 +$script:trackedSessions = [ordered]@{} +# 标题缓存:日志路径 -> 标题(首条提问只读一次) +$script:sessionTitleCache = @{} +# 被用户点掉的:id -> 被点掉时的 cycle +$script:dismissedSessions = @{} +# 有没有变过。没变就不写盘——sessions.json 和 status.json 用同一把锁, +# 每秒无脑写一次会把 hook 的状态推送堵在门外。 +$script:sessionsDirty = $false +# 目录枚举缓存 +$script:lastGroupsScan = [DateTime]::MinValue +$script:lastGroups = @{} + +# 启动补录窗口:detector 重启时,把这段时间内还有动静的 session 收进来。 +# 运行期间完全靠状态机,不看时间。 +$script:SEED_WINDOW_MS = 30 * 60 * 1000 + +# 会话标题:只用会话日志里的首条用户提问。 +# +# 之前这里会绕到运行时的 SQLite 里去读它提炼过的标题,代价是借用 MiniMax +# Code 自己的程序(设一个环境变量让它以 node 模式启动)当 node 用。审核明确 +# 不允许上架版借用那个程序、也不允许读它自己的数据库,所以这条链路整个去掉: +# 标题就是首条提问,截断和脱敏之后照旧能用。 +# +# 脱敏用的 Protect-SecretText 在 scripts/lib/Protect-Text.ps1 里,由 +# detector 在 dot-source 本文件之前加载。lib 被单独 dot-source 时没有它, +# 那就不脱敏(而不是崩)。 +$script:canRedact = [bool](Get-Command Protect-SecretText -ErrorAction SilentlyContinue) + +# Safe traversal of the sessions root (round-24 review). The helper lives in +# scripts/lib/Safe-Traversal.ps1 and is loaded by the detector before this +# file, the same way Protect-Text.ps1 is. +# +# A lib that is dot-sourced on its own does not have it. That case reads +# NOTHING rather than falling back to the blind recursion it replaced: that +# recursion follows reparse points, so a junction under the sessions root +# could hand it a log from outside it. Fail closed. The check happens where +# the traversal runs, not once at load time, so the order of the dot-sources +# cannot change the verdict. + +# 活动窗口:日志刚被写过,session 才算"有活动"。 +# +# 这个窗口只决定"能不能进列表",不决定进列表之后显示什么: +# 进列表之后状态只看最后一条消息。为什么分开: +# +# - "有活动"必须看 mtime。一个几周前卡在 toolCall 上的老对话,最后 +# 一条仍然是 toolCall,消息类型说它"在跑",它其实早就不动了。只看 +# 消息类型会把全部历史会话一次性收进列表(实测 80+ 条)。 +# - "在跑还是在等你"必须看消息。agent 跑一个 40 秒的工具时日志可能 +# 40 秒没写,但它没停;用 mtime 判定状态会把"正在跑长工具"误判成 +# "待回复",而且窗口多长都不对。 +# +# 60 秒足够覆盖 detector 的一次卡顿(它每秒轮询一次)。 +$script:ACTIVE_WINDOW_MS = 60 * 1000 + +# 清理窗口:多久没动的会话从列表里去掉。 +# +# 列表是"现在还活着的会话",不是历史记录。没有清理的话,一上午开二十个 +# 会话,列表就一直挂二十条,sessions.json 也一直长 —— 里面还存着每个会话 +# 说过的话。 +# +# 30 分钟:够覆盖"用户去泡杯茶回来接着聊",又不会把一整个上午的会话 +# 全留在屏幕上。 +$script:PRUNE_AFTER_MS = 30 * 60 * 1000 + +# "运行中" 的活性窗口。 +# +# 状态不能只由消息类型定。agent 跑完一个工具之后,它的最后一条消息 +# 永远是 toolCall 或 toolResult —— 消息类型说它"在跑",其实它可能早就 +# 停了。而日志不再变之后,更新循环会直接跳过,状态会**冻结**在最后 +# 一个值:一个停了快一小时的会话会一直显示"运行中"。 +# +# 所以"在跑"必须有一份活性支撑:超过这个窗口没动,就不是"在跑",是 +# "停了"。 +# +# 5 分钟:长工具(一次几分钟的构建、大文件处理)和长时间的模型推理都 +# 不会误伤,用户也不必等一个小时才看到状态变了。 +# +# "待回复"不看这个窗口:球在用户那边,用户半小时不回,它还是待回复。 +$script:STALE_RUNNING_MS = 5 * 60 * 1000 + +# 一次最多跟多少个会话。 +# +# 列表是"现在还活着的会话",但它不该无限长:一上午开几十个会话,列表会顶 +# 满屏幕,sessions.json 也会长成一本历史书 —— 里面存着每个会话说过的话。 +# 30 分钟的活性窗口管"旧的会消失",这个数管"活的同时有几十个时取哪些": +# 按最后活动时间留最近的这些,其余的淘汰。 +# +# 20 是数据侧的上限;widget 还会按窗口高度再裁一次(见 mcode-island.ps1 的 +# $script:MAX_SESSION_ROWS)。 +$script:MAX_SESSIONS = 20 + +# sessions.json 这个文件本身的上限(字节)。 +# +# 上面那个数管"有多少条",这个管"这个文件多大"。条数上限够不着文件大小: +# 每条会话还带着 titles 历史(最多 8 条),dismissed 那张表也没有条数上限, +# 两者都随使用时长慢慢长。写盘前再按字节砍一遍,砍的顺序是会话优先、 +# dismissed 最后。 +$script:MAX_SESSIONS_JSON_BYTES = 65536 + +# 会话 id 的形状。 +# +# 这个值来自两处,都不是我们生成的: +# 1. 会话目录名里的 base64 —— 解码出来是什么就是什么; +# 2. sessions.json —— 上一次跑留下的,同样不可信。 +# +# 它曾经被拼进一条命令字符串("mcode --session $id" 交给 powershell +# -Command),于是形如 x; Set-Content ... 的 id 能执行任意 PowerShell。 +# 那条路已经删掉了(匹配不上就失败关闭,不再新开终端),这里再把 id 本身 +# 收口:只认字母数字、下划线和连字符。 +# +# 真实的 id 形如 mvs_70378ec639b2471a8f205b954e98a589;测试里也用过 +# e2e-test-001 这种。两者都在这个范围内。 +$script:SESSION_ID_PATTERN = '^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$' + +function Test-SessionIdSafe([string]$id) { + if (-not $id) { return $false } + return [bool]($id -match $script:SESSION_ID_PATTERN) +} + +function Get-SessionIdFromDir($dir) { + $name = [System.IO.Path]::GetFileName($dir) + $i = $name.IndexOf('session_') + if ($i -lt 0) { + if (Test-SessionIdSafe $name) { return $name } + return '' + } + $b64 = $name.Substring($i + 8) + try { + $pad = (4 - ($b64.Length % 4)) % 4 + $decoded = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($b64 + ('=' * $pad))) + # base64 里装的是什么都有可能 —— 解出来先过白名单,不合法就当没有。 + if (Test-SessionIdSafe $decoded) { return $decoded } + return '' + } catch { + if (Test-SessionIdSafe $name) { return $name } + return '' + } +} + +# 一次目录枚举,按 session 分组。 +# +# 目录形态:////-session_/ +# 每个目录里可能有 ledger.jsonl(首选)和 messages.jsonl(fallback)。 +# +# 同一个 session 里两个文件都存在时,取最近被改的那个——和 +# Get-LatestSessionFile 的理由一致:一个陈旧的 ledger 会盖掉活的 +# messages,导致 60s 空闲兜底打在很久以前的时间戳上。 +function Get-SessionLogGroups($root) { + $groups = @{} + if (-not $root -or !(Test-Path -LiteralPath $root)) { return $groups } + # Round-24 review (hetaoBackend): SearchOption.AllDirectories follows + # reparse points, so a junction under the sessions root could redirect the + # walk outside it. Get-SafeSessionEntries refuses reparse-point entries and + # checks canonical containment before a candidate is handed back. + if (-not (Get-Command Get-SafeSessionEntries -ErrorAction SilentlyContinue)) { return $groups } + foreach ($dir in (Get-SafeSessionEntries -Root $root -Pattern '*session_*')) { + $log = $null + $mtime = [DateTime]::MinValue + foreach ($name in @($FNAME_LEDGER, $FNAME_MESSAGES)) { + $p = Join-Path $dir $name + if (!(Test-Path -LiteralPath $p)) { continue } + try { $t = [System.IO.File]::GetLastWriteTimeUtc($p) } catch { continue } + if ($t -gt $mtime) { $mtime = $t; $log = $p } + } + if (-not $log) { continue } + $id = Get-SessionIdFromDir $dir + # 解不出合法 id 就跳过这个目录:没有 id 就没法去重,也没法在列表里 + # 指向它,硬塞一个进去只会让同一条出现好几次($groups[''] 互相覆盖)。 + if (-not $id) { continue } + $groups[$id] = @{ Id = $id; Dir = $dir; Log = $log; MTime = $mtime } + } + return $groups +} + +# 读日志开头,找第一条 user 消息(标题来源)。 +# +# 不是"第一行":压缩过的会话(session compaction)第一行是 +# role=compactionSummary 或 role=custom,不是 user 消息。所以要往下扫。 +# +# 判断一段正文是不是系统注入的消息,而不是用户或 agent 说的话。 +# +# 系统会把后台任务完成通知、任务提醒、agent 上下文这些以 user 角色的 +# 消息塞进会话日志里。它们不是"有人在说话",拿来当标题或摘要,列表上 +# 就会出现一堆 XML 噪声(" Local backend...")。 +function Test-IsSystemInjected($text) { + if (-not $text) { return $true } + $t = ([string]$text).Trim() + if (-not $t) { return $true } + return ($t -match '^<(system-reminder|background-task|task-notification|agent-context|command-|user-message|system-)') +} + +# user 消息的正文前面挂着一整块 (agent-context 注入), +# 把它剥掉,剩下的才是用户真正说的第一句话。 +function Get-SessionTitle($file) { + if (-not $file) { return '' } + if ($script:sessionTitleCache.ContainsKey($file)) { return $script:sessionTitleCache[$file] } + $title = '' + try { + $fs = [System.IO.File]::Open($file, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite) + try { + # 开头 64 KB。user 消息带一张 base64 截图时能到几百 KB,读太多会把 + # 内存吃穿;扫不到就当没有标题,不影响其余功能。 + $cap = 64 * 1024 + $len = [Math]::Min($cap, $fs.Length) + $buf = New-Object byte[] $len + $read = 0 + while ($read -lt $len) { + $n = $fs.Read($buf, $read, $len - $read) + if ($n -le 0) { break } + $read += $n + } + $text = [System.Text.Encoding]::UTF8.GetString($buf, 0, $read) + $assistantFallback = '' + foreach ($raw in ($text -split "`n")) { + $line = $raw.TrimEnd("`r") + if (-not $line -or $line.Length -le 10) { continue } + $msg = $null + try { $msg = $line | ConvertFrom-Json } catch { continue } + $m = $msg.message + if (-not $m) { continue } + $role = [string]$m.role + $t = '' + foreach ($c in @($m.content)) { + if ($c.type -eq 'text' -and $c.text) { $t = [string]$c.text; break } + } + if (-not $t) { continue } + # 剥掉注入块,剩下的才是用户说的话 + $t = $t -replace '(?s).*?', '' + $t = ($t -replace '\s+', ' ').Trim() + if (-not $t) { continue } + # 系统注入的 user 消息不是用户说的话,跳过它继续找。 + if (Test-IsSystemInjected $t) { continue } + if ($role -eq $S_USER) { $title = $t; break } + # 兜底:被压缩过的会话(compaction)里,原始提问已经被 + # compactionSummary 替换掉了,真实标题取不回来。agent 的第一句 + # 话至少能说明这个会话在干什么。 + if (-not $assistantFallback -and $role -eq $S_ASSISTANT) { $assistantFallback = $t } + } + if (-not $title) { $title = $assistantFallback } + } finally { + $fs.Close() + } + } catch { + $title = '' + } + if ($title.Length -gt 40) { $title = $title.Substring(0, 40) + '…' } + $title = Protect-SessionText $title + $script:sessionTitleCache[$file] = $title + return $title +} + +# 这个 session 现在是什么状态。 +# +# **和 detector 的 Infer-State 同源**:同一套状态词表、同一套判定规则。 +# +# 之前这里自己写了一套 running/waiting/stopped,两边越走越远:"用户刚 +# 说完"在 pill 上是 idle(等用户),在列表里却是 running;pill 有的 +# "已完成"(工具跑完)列表里根本没有。同一个会话在两个地方显示成两回事。 +# +# 所以这里直接产出 pill 那套状态: +# +# working agent 在干活(跑工具,或者刚跑完一个工具、还要接着干) +# thinking agent 在想 +# waiting agent 在等审批(ask_user)—— 球在用户那边 +# idle agent 说完了,或者用户刚说完 —— 球在用户那边 +# +# 注意这里**没有** done 和 error:pill 上的"已完成""出错"是"刚刚跑完 +# 一个工具""这个工具炸了"的瞬时反馈,后面还跟着具体动词;放进列表会被 +# 读成"这个会话做完了",而工具跑完之后 agent 还在这一轮里。列表只说 +# 会话级的状态。 +# +# 只产出状态,不产出 message 和 family —— 那两个是 pill 的活("Ran"、 +# "Read" 这些动词),列表不需要。 +function Get-SessionState($msg) { + if (-not $msg) { return '' } + $m = $msg.message + if (-not $m) { + # ledger 事件格式:{kind, phase, action, ...} + $kind = [string]$msg.kind + # 一个工具跑完了,**这一轮还没结束**:agent 还要接着说话或调下一个 + # 工具。所以这里是 working,不是 done。 + if ($kind -eq $S_TOOLRESULT) { return 'working' } + if ($kind -eq 'toolCall' -or $kind -eq $S_TOOLCALL) { + if (Test-IsAskUser ([string]$msg.toolName)) { return 'waiting' } + return 'working' + } + if ($kind -eq $S_ASSISTANT -and [string]$msg.phase -eq $S_THINKING) { return 'thinking' } + return '' + } + $role = [string]$m.role + # 用户刚说完:球在用户那边,agent 马上要动。pill 也判 idle("等用户")。 + if ($role -eq $S_USER) { return 'idle' } + if ($role -eq $S_ASSISTANT) { + # 和 detector 一样扫一遍 content:toolCall 优先,命中就停。 + $hasTool = $null + $hasThink = $null + $hasText = $null + if ($m.content) { + foreach ($c in @($m.content)) { + if ($hasTool -eq $null -and $c.type -eq $S_TOOLCALL) { $hasTool = $c } + elseif ($hasThink -eq $null -and $c.type -eq $S_THINK_BLK) { $hasThink = $c } + elseif ($hasText -eq $null -and $c.type -eq $S_TEXT) { $hasText = $c } + if ($hasTool) { break } + } + } + if ($hasTool) { + if (Test-IsAskUser ([string]$hasTool.name)) { return 'waiting' } + return 'working' + } + if ($hasThink -and -not $hasText) { return 'thinking' } + if ($hasText) { return 'idle' } + return 'idle' + } + # 同上:工具跑完了(成不成都一样),这一轮还在继续。 + # + # 成功和失败都不改状态 —— 工具级的成败是 pill 的事(它绿点/红点一闪, + # 后面还跟着"Read""Edited"这类动词,是瞬时反馈)。会话级的状态是 + # "这一轮还在跑",工具失败也一样:agent 会重试、或者换个办法。 + if ($role -eq $S_TOOLRESULT) { return 'working' } + return '' +} + +# 哪些状态算"一轮的终点":球不在 agent 那边了。 +# +# idle agent 说完了,或者用户刚说完 +# waiting agent 在等审批(ask_user) +# done 工具跑完了 +# stopped agent 挂了 +# error 工具炸了 +# +# 这些状态之后又变成 working/thinking,说明新一轮开始了,被点掉的约束 +# 该失效。 +$script:ROUND_END_STATES = @('idle', 'waiting', 'done', 'stopped', 'error') + +# 哪些状态算"在跑"(agent 那边有活)。活性兜底只对它们生效:最后一条 +# 消息说它在跑,但太久没动静了,那它不是"在跑",是"停了"。 +$script:ACTIVE_STATES = @('working', 'thinking') + +# 最后一条消息的正文摘要(列表的第二行)。 +# +# 从同一条消息里取,零额外 IO:Get-LastJsonLine 已经把它读进内存了, +# Get-SessionState 也已经解析过 $m.content,只是没取正文。 +# +# 这里只负责"把原文取出来",不负责怎么排版:摘要的格式(截断长度、 +# "执行 " 这种前缀)归 widget 管,detector 只给原始数据。这样 +# 换一种排版不用动 detector。 +# +# 取什么,按"用户想看到什么"来定: +# +# assistant 的正文 -> agent 最后说了什么。这是"待回复"会话里最该看到的 +# 一句话:它到底说了什么、要我干什么。 +# user 的正文 -> 用户最后说了什么。 +# toolCall -> 只留工具名,widget 拼成"执行 xxx"。agent 正在干活 +# 时最后一条往往就是 toolCall,没有正文可显示,给个 +# 动作名比留空好。 +# toolResult -> 不取。它的正文是工具输出(代码、日志),不是对话 +# 内容,塞进摘要只会把列表变成日志窗口。 +function Get-SessionPreview($msg) { + if (-not $msg) { return '' } + $m = $msg.message + if (-not $m) { return '' } + $role = [string]$m.role + # toolResult 的正文是工具输出(代码、日志、文件内容),不是对话内容。 + # 当摘要用会把列表变成日志窗口,所以整条排除。 + if ($role -eq $S_TOOLRESULT) { return '' } + $text = '' + foreach ($c in @($m.content)) { + if ($c.type -ne 'text' -or -not $c.text) { continue } + $candidate = ([string]$c.text) -replace '(?s).*?', '' + $candidate = ($candidate -replace '\s+', ' ').Trim() + if (-not $candidate) { continue } + # 系统注入的消息不算"说了什么",跳过它看下一段。 + if (Test-IsSystemInjected $candidate) { continue } + $text = $candidate + break + } + if (-not $text) { return '' } + # 截断 + 脱敏:预览也是"会话里说过的话",会写进 sessions.json,也会画在 + # 屏幕上。超过 120 个字截断,敏感串脱敏。 + if ($text.Length -gt 120) { $text = $text.Substring(0, 120) + '…' } + return (Protect-SessionText $text) +} + +# 最后一条消息里的工具名(toolCall)。 +# +# 和摘要分开放,因为它们的来源不同:摘要是正文,工具名只在最后一条 +# 恰好是 toolCall 时才有。widget 先用摘要,没有再用它兜底。 +# +# toolResult 消息也带工具名,但不在 content 里,而在 message.toolName +# 上。agent 跑完一个工具之后,最后一条往往正是 toolResult —— 实测下来 +# 那样 preview 是空的(工具输出不当摘要),content 里也没有 toolCall, +# 工具名就漏了,第二行会整个空着。所以这里要认 message.toolName。 +function Get-SessionLastTool($msg) { + if (-not $msg) { return '' } + $m = $msg.message + if (-not $m) { return '' } + foreach ($c in @($m.content)) { + if ($c.type -eq 'toolCall' -and $c.name) { return [string]$c.name } + } + if ($m.PSObject.Properties['toolName'] -and $m.toolName) { return [string]$m.toolName } + return '' +} + +# 脱敏:写盘和显示之前都要过一遍。 +# +# 标题和预览都是**会话里说过的话**,里面可能有 token、密码、身份证号这种 +# 东西 —— 列表把它们写进 sessions.json,还画在屏幕上。所以两处都先脱敏。 +function Protect-SessionText([string]$text) { + if (-not $text) { return '' } + if (-not $script:canRedact) { + # 第一次调用时再确认一次:lib 被谁、在什么顺序 dot-source 都会变, + # 脱敏函数晚到也不该让它永远失效。 + $script:canRedact = [bool](Get-Command Protect-SecretText -ErrorAction SilentlyContinue) + } + if (-not $script:canRedact) { return $text } + try { return [string](Protect-SecretText -Text $text) } catch { return $text } +} + +# 这个 session 在列表里显示的名字。 +# +# 只有首条用户提问这一个来源:运行时自己的 SQLite 上架版不许读,借用 +# MiniMax Code 的程序当 node 用也不许。 +function Get-SessionDisplayTitle([string]$log) { + return (Get-SessionTitle $log) +} + +# 消掉一个 session(用户点了)。 +# 记下被点掉时的 cycle:同一个 cycle 内不会再冒出来;agent 跑完一轮再 +# 开始(waiting -> running)算新的一轮,那时它可以重新进来。 +function Remove-TrackedSession($id) { + $entry = $script:trackedSessions[$id] + if ($entry) { + # 记下消掉时它是什么状态:只有"它跑完一轮又开始了"才该重新出现。 + # 消掉的时候正在跑 -> 继续跑的过程中不该冒出来。 + $script:dismissedSessions[$id] = @{ Cycle = $entry.Cycle; Phase = $entry.Phase } + $script:trackedSessions.Remove($id) + $script:sessionsDirty = $true + return $true + } + return $false +} + +# 消费用户点掉的信号。 +# +# 复用插件里已有的 show.signal 模式:widget 建一个文件,detector 看到就 +# 处理。ack.signal 里写要消掉的 session id。 +# +# **一行一个 id,可以有多行。** +# +# 以前是一个文件一个 id。用户在一秒内点两行,后写的盖掉先写的,那一条 +# 就永远消不掉。现在列表每行都有 × 可以单独消除,"一秒内消掉两三条"是很 +# 正常的操作,所以改成追加。 +# +# 消费方式是**改名**,不是读+删: +# 读+删有两个窗口期 —— 读到之后、删之前,widget 写进来的新 id 会被 +# 一起删掉(那一条就丢了);而且读到的可能是写了一半的文件。 +# 改名是原子的:rename 之后 widget 的下一次写入落在新的 ack.signal 里, +# 不会被这一轮带走。 +function Read-AckSignal { + $ackFile = Join-Path $configDir 'ack.signal' + if (!(Test-Path -LiteralPath $ackFile)) { return @() } + # 先改名再读。改名失败(文件刚好被另一个进程动了)就放弃这一轮,下一 + # 轮再来 —— 总比读到一个写了一半的文件强。 + $taken = $ackFile + '.taken' + try { + Move-Item -LiteralPath $ackFile -Destination $taken -Force -ErrorAction Stop + } catch { + return @() + } + $ids = @() + try { + $raw = [System.IO.File]::ReadAllText($taken) + foreach ($line in ($raw -split "`r?`n")) { + $id = $line.Trim() + if (-not $id) { continue } + $ids += $id + } + } catch { + $ids = @() + } finally { + try { Remove-Item -LiteralPath $taken -Force -ErrorAction SilentlyContinue } catch {} + } + return @($ids) +} + +# 每轮更新一次 session 列表。 +function Update-SessionTracker { + # 用户点掉的先消化掉。一次可能有多个(一行一个 id)。 + foreach ($acked in @(Read-AckSignal)) { + if (Remove-TrackedSession $acked) { Log-Line "session ack: $acked" } + } + + $groups = Get-SessionLogGroups $sessionsRoot + $now = [DateTime]::UtcNow + $nowMs = [int64]([DateTimeOffset]::new($now).ToUnixTimeMilliseconds()) + + foreach ($id in @($groups.Keys)) { + $g = $groups[$id] + $entry = $script:trackedSessions[$id] + if ($entry -and $entry.MTime -eq $g.MTime) { + # 日志没变:消息没变,状态也不该变。 + # + # 但"运行中"需要一份活性支撑。agent 跑完一个工具之后,它的最后 + # 一条消息永远是 toolCall 或 toolResult —— 消息类型说它"在跑", + # 其实它可能早就停了。而这一行原本直接 continue,状态会冻结在 + # 最后一个值:一个停了快一小时的会话会一直显示"运行中"。 + # + # 所以在这里降级一次:超过窗口没动就不是"在跑",是"停了"。 + if ($entry.Phase -in $script:ACTIVE_STATES) { + $ageMs = ($now - $g.MTime).TotalMilliseconds + if ($ageMs -gt $script:STALE_RUNNING_MS) { + $entry.Phase = 'stopped' + $script:sessionsDirty = $true + Log-Line ("session stopped: " + $id + " (quiet for " + [int]($ageMs / 1000) + "s)") + } + } + continue + } + + # 先判断过期,再读日志。 + # + # 之前是先 Get-LastJsonLine 再判过期:本机 400 多个历史会话的日志每秒 + # 都被读一遍,读完再丢掉(日志动过才需要重读,没动过的那 400 个根本 + # 不该被碰)。审核指出这个顺序反了。 + if (-not $entry) { + $ageMs = ($now - $g.MTime).TotalMilliseconds + if ($ageMs -gt $script:ACTIVE_WINDOW_MS) { continue } + } + + $msg = Get-LastJsonLine $g.Log + if (-not $msg) { continue } + $phase = Get-SessionState $msg + + if (-not $entry) { + # 新 session:只有"刚动过"才进列表。 + # + # 活动与否看日志 mtime,不看最后一条消息的类型——一个几周前卡在 + # toolCall 上的老对话,最后一条仍然是 toolCall,消息类型说它 + # "在跑",它其实早就不动了。 + $cycle = 1 + if ($script:dismissedSessions.ContainsKey($id)) { + $d = $script:dismissedSessions[$id] + # 被点掉过。上次看到它是一轮的终点,现在又在跑 -> + # 它跑完了一轮又开始,是新的一轮,可以重新进来。 + if (($d.Phase -in $script:ROUND_END_STATES) -and ($phase -in $script:ACTIVE_STATES)) { + $cycle = $d.Cycle + 1 + $script:dismissedSessions.Remove($id) + $script:sessionsDirty = $true + } else { + # 还没到新一轮:只更新它现在的状态,等它"待回复 -> 运行中"的那 + # 一次转换时再放回来。 + $d.Phase = $phase + continue + } + } + $script:trackedSessions[$id] = @{ + Id = $id + Title = (Get-SessionDisplayTitle $g.Log) + Phase = $phase + Cycle = $cycle + MTime = $g.MTime + SinceMs = [int64]([DateTimeOffset]::new($g.MTime).ToUnixTimeMilliseconds()) + # 列表的第二行和右侧时间戳。 + # Preview = 最后一条消息的正文(agent 或用户说的) + # LastTool = 最后一条消息恰好是 toolCall 时的工具名 + # LastAtMs = 最后一次活动时间(日志 mtime)。不是"进列表的时间", + # 那是 SinceMs;列表右侧显示的是这个。 + Preview = (Get-SessionPreview $msg) + LastTool = (Get-SessionLastTool $msg) + # 见过的所有标题。终端 tab 上的名字是 mcode 在某个时刻设的,而 + # 会话标题会随对话推进变,所以要留着历史才匹配得上那个 tab。 + # + # 首条用户消息也要放进去:tab 上的名字通常就是会话刚起时那个 + # 名字,而运行时那个标题是后来变的。 + Titles = @(@((Get-SessionDisplayTitle $g.Log)) | Where-Object { $_ } | Select-Object -Unique) + LastAtMs = [int64]([DateTimeOffset]::new($g.MTime).ToUnixTimeMilliseconds()) + Dir = $g.Dir + } + $script:sessionsDirty = $true + Log-Line "session tracked: $id ($phase)" + continue + } + + # 已跟踪的:状态变了就更新 + # + # 一轮的终点(idle/waiting/done/stopped/error)之后又变成在跑 + # (working/thinking),说明新一轮开始了,之前被点掉的约束失效。 + if (($entry.Phase -in $script:ROUND_END_STATES) -and ($phase -in $script:ACTIVE_STATES)) { + # 一轮结束了,agent 又开始 -> 新的一轮,之前被点掉的约束失效 + $entry.Cycle = $entry.Cycle + 1 + if ($script:dismissedSessions.ContainsKey($id)) { + $script:dismissedSessions.Remove($id) + } + $script:sessionsDirty = $true + Log-Line "session restarted: $id (cycle $($entry.Cycle))" + } + if ($entry.Phase -ne $phase) { $script:sessionsDirty = $true } + $entry.Phase = $phase + $entry.MTime = $g.MTime + # 第二行和右侧时间戳跟着最后一条消息走。 + # + # 变了就置 dirty。这里原来只看 Phase 变没变:agent 连着跑几个工具时 + # Phase 一直是 running,摘要和最后活动时间却在变,不置 dirty 的话 + # 磁盘上的 sessions.json 会一直停在旧值 —— 列表第二行空着、右侧的 + # 时间戳也不动。内存里是对的,落到文件上却丢了。 + $newPreview = (Get-SessionPreview $msg) + $newTool = (Get-SessionLastTool $msg) + $newAtMs = [int64]([DateTimeOffset]::new($g.MTime).ToUnixTimeMilliseconds()) + if ($entry.Preview -ne $newPreview -or $entry.LastTool -ne $newTool -or $entry.LastAtMs -ne $newAtMs) { + $script:sessionsDirty = $true + } + $entry.Preview = $newPreview + $entry.LastTool = $newTool + $entry.LastAtMs = $newAtMs + # 标题变了就记进历史(最多留 8 个,够覆盖 tab 标题那个时刻)。 + # + # 无条件并入当前标题:detector 重启后条目是从 sessions.json 恢复的, + # 恢复出来的标题和当前标题一样,光靠"变了才记"会一个都记不上。 + $newTitle = (Get-SessionDisplayTitle $g.Log) + if ($newTitle) { + # 首条用户消息一起并进来:tab 名多半是会话刚起时的那个名字。 + $hist = @(@($entry.Titles) + @($newTitle) | Where-Object { $_ } | Select-Object -Unique) + if ($hist.Count -gt 8) { $hist = @($hist | Select-Object -Last 8) } + $entry.Titles = $hist + } + if (-not $entry.Title) { $entry.Title = (Get-SessionTitle $g.Log) } + } + + # 清理:太久没动的会话从列表里去掉。 + # + # 列表是"现在还活着的会话",不是历史记录。没有清理的话,一个上午开了 + # 二十个会话,列表就一直挂二十条,sessions.json 也一直长 —— 而且里面 + # 存着每个会话说过的话。 + # + # 判定用最后活动时间(LastAtMs):还在动的会话不会被清掉。30 分钟不动 + # 就当成"已经不活了",从内存和文件里一起删掉。 + $pruned = 0 + foreach ($id in @($script:trackedSessions.Keys)) { + $e = $script:trackedSessions[$id] + $lastMs = $e.LastAtMs + if (-not $lastMs) { $lastMs = $e.SinceMs } + if (-not $lastMs) { continue } + if (($nowMs - [int64]$lastMs) -gt $script:PRUNE_AFTER_MS) { + $script:trackedSessions.Remove($id) + $script:dismissedSessions.Remove($id) + $pruned++ + } + } + if ($pruned -gt 0) { + $script:sessionsDirty = $true + Log-Line ("sessions pruned: " + $pruned) + } + + # 活性窗口管不住"同一时刻活着的会话很多":一上午开几十个,30 分钟内它们 + # 全都还在。这里再按最后活动时间留最近的 MAX_SESSIONS 个,其余淘汰 —— + # 列表有界,sessions.json 也有界。 + # + # 只淘汰**已经不活跃**的会话(日志超过活性窗口没动)。不然有个更糟的后果: + # 被淘汰的会话日志还在活性窗口里,下一次扫描会把它当"新会话"重新收进来, + # 于是每个 tick 都在"淘汰 → 重新加入",列表闪烁、SinceMs 和 Cycle 每轮 + # 归零。不活跃的会话淘汰后不会被捞回来(扫描只收窗口内动过的),等它真的 + # 又有动静才以新的一轮回来。 + # + # 代价是"同时活跃的会话超过上限时列表会短暂超一点"。这是有意的:砍掉一个 + # 正在干活的会话,比多画两行糟得多;屏幕那一侧还有行数上限兜着。 + $over = [int]$script:trackedSessions.Count - [int]$script:MAX_SESSIONS + if ($over -gt 0) { + $idle = @() + foreach ($id in @($script:trackedSessions.Keys)) { + $e = $script:trackedSessions[$id] + $lastMs = $e.LastAtMs + if (-not $lastMs) { $lastMs = $e.SinceMs } + $ageMs = if ($lastMs) { $nowMs - [int64]$lastMs } else { [int64]::MaxValue } + if ($ageMs -gt $script:ACTIVE_WINDOW_MS) { + $idle += [PSCustomObject]@{ Id = $id; Last = $(if ($lastMs) { [int64]$lastMs } else { [int64]0 }) } + } + } + $victims = @($idle | Sort-Object Last | Select-Object -First $over | ForEach-Object { $_.Id }) + foreach ($id in $victims) { + $script:trackedSessions.Remove($id) + # 淘汰的会话也不该在 dismissed 里留一辈子的记录。 + $script:dismissedSessions.Remove($id) + } + if ($victims.Count -gt 0) { + $script:sessionsDirty = $true + Log-Line ("sessions capped: dropped " + $victims.Count + " (limit " + $script:MAX_SESSIONS + ")") + } + } +} + +# 把 sessions.json 压到字节上限以内。 +# +# 砍的顺序有讲究:会话按最后活动时间从旧到新丢(用户最可能还在看的留着), +# 都丢完了还不够才清 dismissed。返回最终要写盘的 JSON 文本。 +function Limit-SessionsJson($items, $dismissed, [int64]$maxBytes) { + $utf8 = [System.Text.Encoding]::UTF8 + # WriteAllText 带 BOM,文件比字符串多这几个字节,一起算进上限。 + $overhead = $utf8.GetPreamble().Length + $keep = @($items) + $dismiss = @{} + foreach ($k in @($dismissed.Keys)) { $dismiss[$k] = $dismissed[$k] } + $dropped = 0 + while ($true) { + $payload = [PSCustomObject]@{ + sessions = $keep + dismissed = $dismiss + ts = (Get-Date).ToString('o') + } + $json = $payload | ConvertTo-Json -Compress -Depth 6 + if (($utf8.GetByteCount($json) + $overhead) -le $maxBytes) { + if ($dropped -gt 0) { + Log-Line ("sessions.json byte cap: dropped " + $dropped + " row(s) (limit " + $maxBytes + " bytes)") + } + return $json + } + if ($keep.Count -gt 0) { + $oldest = @($keep | Sort-Object { [int64]$_.lastAtMs })[0] + $oldestId = [string]$oldest.id + $keep = @($keep | Where-Object { [string]$_.id -ne $oldestId }) + $dropped++ + continue + } + if ($dismiss.Count -gt 0) { + $dismiss = @{} + continue + } + # 会话和 dismissed 都清空了还是超:上限小得离谱,写一个空壳。 + return '{"sessions":[],"dismissed":{},"ts":"' + (Get-Date).ToString('o') + '"}' + } +} + +# 写 sessions.json。 +# +# 只有 detector 写这个文件,所以不存在 agent 侧整份覆盖把它抹掉的问题。 +# 但它和 status.json 在同一个目录、同一台机器上,用同一把锁写,免得两个 +# 进程同时动这个目录。 +function Write-SessionsFile { + param([switch]$Force) + if (-not $Force -and -not $script:sessionsDirty) { return } + $items = @() + foreach ($id in @($script:trackedSessions.Keys)) { + $e = $script:trackedSessions[$id] + $items += [PSCustomObject]@{ + id = $e.Id + title = $e.Title + phase = $e.Phase + sinceMs = $e.SinceMs + cycle = $e.Cycle + # 列表第二行 / 右侧时间戳。缺字段的旧条目补空串,不要 null: + # widget 那边按字符串处理,null 会多一层判空。 + preview = $(if ($e.Preview) { [string]$e.Preview } else { '' }) + lastTool = $(if ($e.LastTool) { [string]$e.LastTool } else { '' }) + lastAtMs = $(if ($e.LastAtMs) { [int64]$e.LastAtMs } else { 0 }) + # 见过的所有标题,widget 拿它去匹配终端 tab。 + titles = @($e.Titles | Where-Object { $_ }) + } + } + $lock = Enter-StatusLock + try { + $file = Join-Path $configDir 'sessions.json' + # 字节上限:条数上限管"有多少条",这个管"这个文件多大"。 + $json = Limit-SessionsJson $items $script:dismissedSessions $script:MAX_SESSIONS_JSON_BYTES + $tmp = "$file.tmp" + [System.IO.File]::WriteAllText($tmp, $json, [System.Text.Encoding]::UTF8) + Move-Item -Path $tmp -Destination $file -Force + $script:sessionsDirty = $false + } catch { + Log-Line "SESSIONS WRITE FAIL: $($_.Exception.Message)" + } finally { + Exit-StatusLock $lock + } +} + +# detector 启动时把列表读回来(detector 重启不能把用户的"待回复"清零)。 +function Restore-SessionTracker { + $file = Join-Path $configDir 'sessions.json' + if (!(Test-Path -LiteralPath $file)) { return } + try { + $j = [System.IO.File]::ReadAllText($file) | ConvertFrom-Json + foreach ($s in @($j.sessions)) { + if (-not $s.id) { continue } + # sessions.json 是上一次跑写下的,里面可能有任何东西 —— 包括被手改过 + # 的 id。不合法的直接丢掉,不让它进入列表。 + if (-not (Test-SessionIdSafe ([string]$s.id))) { + Log-Line "sessions restore: dropped entry with an unsafe id" + continue + } + $script:trackedSessions[$s.id] = @{ + Id = $s.id + Title = [string]$s.title + Phase = [string]$s.phase + Cycle = $(if ($s.cycle) { [int]$s.cycle } else { 1 }) + MTime = [DateTime]::MinValue + SinceMs = [int64]$s.sinceMs + # 第二行和右侧时间戳也要恢复:detector 重启到下一次刷新之间, + # widget 读的就是这个文件,不能白屏一行。 + Preview = $(if ($s.preview) { [string]$s.preview } else { '' }) + LastTool = $(if ($s.lastTool) { [string]$s.lastTool } else { '' }) + LastAtMs = $(if ($s.lastAtMs) { [int64]$s.lastAtMs } else { 0 }) + Titles = @($s.titles | Where-Object { $_ }) + Dir = '' + } + } + if ($j.dismissed) { + foreach ($p in $j.dismissed.PSObject.Properties) { + $v = $p.Value + # 老格式是一个整数(cycle)。当成"消掉时正在跑",于是它要等 + # 跑完一轮又开始才回来。 + if ($v -is [int] -or $v -is [long] -or $v -is [double]) { + $v = @{ Cycle = [int]$v; Phase = 'working' } + } + $script:dismissedSessions[$p.Name] = $v + } + } + Log-Line ("sessions restored: " + $script:trackedSessions.Count) + } catch { + Log-Line "SESSIONS RESTORE FAIL: $($_.Exception.Message)" + } +} + +# 启动补录:把最近还在动的 session 收进来。 +# +# 只在 detector 启动时跑一次。运行期完全靠状态机。 +# +# 为什么需要它:detector 经常重启(per-tick guard、autostart、崩溃)。如果 +# 只靠"看到它从 running 变成 waiting"这个转换,那么一个在 detector 重启 +# 之前刚说完话的 session 就永远等不到那次转换,用户永远看不到它的"待回复"。 +# 这个窗口是唯一一处还看时间的地方——30 分钟不算"很久没用",而且运行期 +# 不再依赖它。 +function Seed-SessionTracker { + $groups = Get-SessionLogGroups $sessionsRoot + $now = [DateTime]::UtcNow + $activeCut = $now.AddMilliseconds(-1 * $script:ACTIVE_WINDOW_MS) + $seedCut = $now.AddMilliseconds(-1 * $script:SEED_WINDOW_MS) + foreach ($id in @($groups.Keys)) { + $g = $groups[$id] + # trackedSessions 是 [ordered]@{}(OrderedDictionary),它没有 + # ContainsKey,只有 Contains。写成 ContainsKey 会在这里抛 + # "OrderedDictionary 不包含名为 ContainsKey 的方法",把整个补录 + # 堵掉 —— 症状是列表只剩恢复回来的那几条。 + if ($script:trackedSessions.Contains($id)) { continue } + if ($script:dismissedSessions.ContainsKey($id)) { continue } + + $msg = Get-LastJsonLine $g.Log + if (-not $msg) { continue } + $phase = Get-SessionState $msg + + # 两种补录: + # - 刚动过(60 秒内):它此刻真的在跑 + # - 说完在等你(30 分钟内,且最后一条是 agent 收尾):detector + # 重启前它刚说完,那次"从运行中变成待回复"的转换我们没看见, + # 不补录的话这个"待回复"就永远等不到了 + $isActive = ($g.MTime -ge $activeCut) + $isFinishedWaiting = ($g.MTime -ge $seedCut) -and ($phase -in $script:ROUND_END_STATES) + if (-not $isActive -and -not $isFinishedWaiting) { continue } + + $script:trackedSessions[$id] = @{ + Id = $id + Title = (Get-SessionTitle $g.Log) + Phase = $phase + Cycle = 1 + MTime = $g.MTime + SinceMs = [int64]([DateTimeOffset]::new($g.MTime).ToUnixTimeMilliseconds()) + Dir = $g.Dir + } + $script:sessionsDirty = $true + Log-Line "session seeded: $id ($phase)" + } +} diff --git a/plugins/antianqi/mcode-island/scripts/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..7245cc44 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'; @@ -81,6 +81,55 @@ const checkLiteralPaths = (s, where) => { } }; +// Strip XML comments out of an XAML block. +// +// NOT `replace(//g, '')`: that regex only removes a +// ``, so an unclosed `" as a comment end as well as "-->", so a filter that +// only knows "-->" leaves such a comment open (CodeQL: +// js/bad-html-filtering-regexp). +const xmlCommentEndAt = (s, from) => { + const m = /--!?>/.exec(s.slice(from)); + if (!m) return null; + return { at: from + m.index, len: m[0].length }; +}; + +const stripXmlComments = (s) => { + let keep = ''; + let i = 0; + while (i < s.length) { + const open = s.indexOf('" is 3 chars, "--!>" is 4 + } + return keep; +}; + +// True when every `", + // so without a probe a strip that only knew "-->" would look + // perfect here. + const probe = 'keep'; + if (stripXmlComments(probe) === 'keep') { + out('PASS', 'xaml comments: the strip recognises every HTML comment end tag'); + } else { + out('FAIL', 'xaml comments: the strip does not recognise every HTML comment end tag -- "--!>" closes a comment too'); + } + + const numOf = (name) => { + const m = psCode.match(new RegExp('\\$script:' + name + '\\s*=\\s*([0-9.]+)')); + return m ? parseFloat(m[1]) : null; + }; + const needNum = (name) => { + const v = numOf(name); + if (v === null) { + out('FAIL', `mcode-island.ps1: $script:${name} is missing or not a plain number -- the material contract cannot be verified`); + return null; + } + return v; + }; + + // --- 6.1 材质没有固有颜色 ----------------------------------- + // HIG "Color": "Liquid Glass has no inherent color, and instead + // takes on colors from the content directly behind it". + // + // 旧实现是画上去的半透明深蓝灰(#99232734 那一套)。那不是玻璃, + // 是"一块染了色的矩形"。所以那三个色值必须消失,而且玻璃的底色 + // 必须由运行时抓屏刷上去。 + const legacyTints = ['#99232734', '#7312161C', '#851A1E28']; + const legacyHits = legacyTints.filter((h) => src.toUpperCase().includes(h.toUpperCase())); + if (legacyHits.length === 0) { + out('PASS', 'glass material: the legacy hardcoded tint colours are gone (the material takes its colour from what is behind it)'); + } else { + out('FAIL', `glass material: still carries the legacy hardcoded tint ${legacyHits.join(', ')} -- a tinted plate is not glass`); + } + if (/Background\s*=\s*\$pillCap\.Brush/.test(psCode) && /Background\s*=\s*\$panelCap\.Brush/.test(psCode)) { + out('PASS', 'glass material: both glass surfaces are filled from the captured backdrop at runtime'); + } else { + out('FAIL', 'glass material: the glass surfaces are not filled from the captured backdrop (the material would keep a fixed colour)'); + } + + // --- 6.2 抓屏必须把自己排除掉 ------------------------------- + // 不排除的话抓到的是自己的上一帧:模糊之后是一团脏影子,而不是 + // 身后的桌面。实测排除前采样点全是窗口自己的颜色。 + if (/SetWindowDisplayAffinity\(\$hwnd,\s*0x00000011\)/.test(psCode)) { + out('PASS', 'glass capture: the widget excludes itself from the screen grab (WDA_EXCLUDEFROMCAPTURE)'); + } else { + out('FAIL', 'glass capture: the widget does not exclude itself from the screen grab -- it would blur its own previous frame'); + } + + // --- 6.3 离屏渲染必须先 Measure + Arrange ------------------- + // RenderTargetBitmap.Render() 只画"已经摆好"的视觉对象。离屏的 + // Image 从没被排过版,Render 出来就是一张全黑:实测同一张图,不 + // 排版亮度 0.0,排版后 0.35。 + const mIdx = psCode.indexOf('$img.Measure('); + const aIdx = psCode.indexOf('$img.Arrange('); + const rIdx = psCode.indexOf('$rtb.Render($img)'); + if (mIdx >= 0 && aIdx > mIdx && rIdx > aIdx) { + out('PASS', 'glass capture: the offscreen image is measured and arranged before it is rendered (otherwise it renders black)'); + } else { + out('FAIL', 'glass capture: the offscreen image is rendered without a prior Measure/Arrange -- the blurred backdrop would come out black'); + } + + // --- 6.4 形状用 Clip,不是 CornerRadius --------------------- + // Border 的 CornerRadius 不裁剪子元素,光靠它画出来是方的。 + if (/\$glass\.Clip\s*=\s*\$geo/.test(psCode) && /function New-RoundedRectGeometry/.test(psCode)) { + out('PASS', 'glass shape: the material is clipped by a real rounded path (Border.CornerRadius would not clip the layers)'); + } else { + out('FAIL', 'glass shape: the glass surface is not clipped by a rounded path (the corners render square)'); + } + // 面板要走苹果的"连续圆角"(超椭圆),药丸是正圆弧的胶囊。 + if (/New-RoundedRectGeometry\s+\$w\s+\$h\s+\$Radius\s+\$Smooth\s+0/.test(psCode) + && /New-RoundedRectGeometry\s+\$w\s+\$h\s+\$Radius\s+\$Smooth\s+1\.25/.test(psCode)) { + out('PASS', 'glass shape: the rim and the edge strokes are inset paths of the same rounded geometry'); + } else { + out('FAIL', 'glass shape: the rim/edge strokes are not inset paths of the glass geometry (they would not follow the corner radius)'); + } + + // --- 6.5 模糊半径按物理像素折算 ----------------------------- + // 降采样 1/4 再模糊,Radius 必须按折算后的尺度给。写死 9 的话, + // 70px 高的药丸(缩小后只有 17px)会被抹成一块纯色板。 + const sd = psCode.match(/\$scaleDown\s*=\s*([0-9.]+)/); + const br = psCode.match(/\$blur\.Radius\s*=\s*([0-9.]+)/); + if (sd && br) { + const phys = parseFloat(sd[1]) * parseFloat(br[1]); + if (phys >= 16 && phys <= 48) { + out('PASS', `glass blur: the blur radius is scaled for physical pixels (${sd[1]}x downscale x ${br[1]} = ${phys}px)`); + } else { + out('FAIL', `glass blur: the effective blur is ${phys}px (${sd[1]}x downscale x ${br[1]}) -- outside the 16..48px band that reads as frosted glass`); + } + } else { + out('FAIL', 'glass blur: $scaleDown / $blur.Radius not found (the blur scale cannot be verified)'); + } + + // --- 6.6 颗粒要克制 ----------------------------------------- + if (/Opacity\s*=\s*0\.0[0-2]/.test(psCode)) { + out('PASS', 'glass grain: the grain layer stays below 3% opacity (it is a surface frost, not a texture)'); + } else { + out('FAIL', 'glass grain: the grain opacity is not a small value (a visible speckle destroys the frosted look)'); + } + + // --- 6.7 两极的前景色 / 状态色 / 家族色表 -------------------- + const inkOf = (name) => { + const m = psCode.match(new RegExp('\\$script:' + name + '\\s*=\\s*@\\{([\\s\\S]*?)\\n\\}')); + const out = {}; + if (!m) return out; + const re = /[ \t]*([A-Za-z]+)[ \t]*=[ \t]*'(#[0-9A-Fa-f]{8})'/g; + let mm; + while ((mm = re.exec(m[1])) !== null) out[mm[1]] = mm[2]; + return out; + }; + const inkDark = inkOf('glassInkDark'); + const inkLight = inkOf('glassInkLight'); + if (Object.keys(inkDark).length >= 8 && Object.keys(inkLight).length >= 8) { + out('PASS', `glass ink: both poles define a full foreground palette (dark ${Object.keys(inkDark).length}, light ${Object.keys(inkLight).length} entries)`); + } else { + out('FAIL', `glass ink: the foreground palettes are incomplete (dark ${Object.keys(inkDark).length}, light ${Object.keys(inkLight).length} entries; need >= 8 each)`); + } + // 两套色表的键必须一样 —— 少一个键就是某个字在浅色极上没人管。 + const inkKeys = Object.keys(inkDark).sort().join(','); + const inkKeysLight = Object.keys(inkLight).sort().join(','); + if (inkKeys && inkKeys === inkKeysLight) { + out('PASS', 'glass ink: the two poles expose the same foreground roles'); + } else { + out('FAIL', 'glass ink: the two poles expose different foreground roles (a control would keep the wrong colour on one pole)'); + } + + // 状态色表 / 家族色表也要成对,且键集一致。 + const mapKeys = (srcText, varName) => { + const m = srcText.match(new RegExp('\\$' + varName + '\\s*=\\s*@\\{([\\s\\S]*?)\\n\\}')); + if (!m) return null; + const keys = []; + const re = /^[ \t]*([A-Za-z][A-Za-z0-9]*)[ \t]*=/gm; + let mm; + while ((mm = re.exec(m[1])) !== null) keys.push(mm[1]); + return keys; + }; + const stateDark = mapKeys(psCode, 'stateMap'); + const stateLight = mapKeys(psCode, 'stateMapLight'); + const famDark = mapKeys(psCode, 'familyMap'); + const famLight = mapKeys(psCode, 'familyMapLight'); + if (stateDark && stateLight && stateDark.slice().sort().join(',') === stateLight.slice().sort().join(',')) { + out('PASS', 'glass colour maps: the state table has a light-pole twin with the same states'); + } else { + out('FAIL', 'glass colour maps: the state table and its light-pole twin cover different states'); + } + if (famDark && famLight && famDark.slice().sort().join(',') === famLight.slice().sort().join(',')) { + out('PASS', 'glass colour maps: the family table has a light-pole twin with the same families'); + } else { + out('FAIL', 'glass colour maps: the family table and its light-pole twin cover different families'); + } + // 家族色两两之间要分得开(CIE76 dE),两极都要查。 + const deltaE = (hexA, hexB) => { + 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 la = toLab(hexA), lb = toLab(hexB); + return Math.sqrt((la[0] - lb[0]) ** 2 + (la[1] - lb[1]) ** 2 + (la[2] - lb[2]) ** 2); + }; + const famBlockOf = (srcText, varName) => { + const m = srcText.match(new RegExp('\\$' + varName + '\\s*=\\s*@\\{([\\s\\S]*?)\\n\\}')); + return m ? m[1] : ''; + }; + const famHexes = (block) => { + const map = new Map(); + const re = /[ \t]*([a-z]+)[ \t]*=[ \t]*'(#[0-9A-Fa-f]{8})'/g; + let mm; + while ((mm = re.exec(block)) !== null) map.set(mm[1], mm[2].slice(3).toUpperCase()); + return map; + }; + const sepCheck = (label, block) => { + const colors = famHexes(block); + if (colors.size < 7) { + out('FAIL', `glass colour maps: ${label} parses only ${colors.size}/7 family colours -- the separation check below would be vacuous`); + return; + } + let worst = Infinity, worstPair = ''; + const entries = [...colors.entries()]; + for (let i = 0; i < entries.length; i++) { + for (let j = i + 1; j < entries.length; j++) { + const d = deltaE(entries[i][1], entries[j][1]); + if (d < worst) { worst = d; worstPair = `${entries[i][0]}/${entries[j][0]}`; } + } + } + if (worst >= 20) { + out('PASS', `glass colour maps: ${label} family colours are all separable (closest pair ${worstPair} dE ${worst.toFixed(1)} >= 20)`); + } else { + out('FAIL', `glass colour maps: ${label} family colours ${worstPair} are only dE ${worst.toFixed(1)} apart -- they read as the same colour on the pill`); + } + }; + sepCheck('dark pole', famBlockOf(psCode, 'familyMap')); + sepCheck('light pole', famBlockOf(psCode, 'familyMapLight')); + + // --- 6.8 换极阈值 + 对比度保证 ------------------------------ + // + // 这里是这一节的核心:把源码里的材质参数读出来,按**最坏背景** + // 复算前景/背景的对比度。 + // + // 模型跟代码一一对应: + // - 玻璃的底色 = 背后屏幕的模糊图(L 是它的加权亮度)。 + // - 染色层是渐变色标:暗色极有"暗背景/亮背景"两套,浅色极一套。 + // - 标题行落在渐变偏移 0.25 附近,正文行落在 0.55 附近 + // (药丸高 70,内容上边距 6,标题行高约 22)。 + // - 前景色是带透明度的墨色,合成到玻璃上再算 WCAG 对比度。 + const P = { + lightOn: needNum('glassPoleLightOn'), + lightOff: needNum('glassPoleLightOff'), + targetDark: needNum('glassTargetByteDark'), + targetLight: needNum('glassTargetByteLight'), + thickDark: needNum('glassThickDark'), + thickLight: needNum('glassThickLight'), + }; + const allNum = Object.values(P).every((v) => v !== null); + if (!allNum) { + out('FAIL', 'glass contrast: the material constants are incomplete -- the legibility guarantee cannot be verified'); + } else { + // 迟滞:两个阈值必须分得开,否则背景亮度在阈值附近抖一下, + // 药丸就跟着闪一下。 + if (P.lightOn > P.lightOff) { + out('PASS', `glass pole: the light/dark switch is hysteretic (on at ${P.lightOn}, back at ${P.lightOff})`); + } else { + out('FAIL', `glass pole: the light switch (${P.lightOn}) is not above the dark switch (${P.lightOff}) -- the material would flicker`); + } + + // 渐变色标:只取染色层($tintBrush)上的那些,边缘高光和 + // 厚度暗边用的是同一套 New-GlassStop,按颜色过滤掉。 + const tintStops = (block) => { + const stops = []; + for (const line of block.split(/\r?\n/)) { + if (!/\$tintBrush\.GradientStops\.Add\(/.test(line)) continue; + const m = line.match(/New-GlassStop\s+\$(white|dark)\s+([0-9.]+)\s+\(([^)]*)\)/); + if (!m) continue; + const expr = m[3].trim(); + let factor = null, lit = null, over = null; + const vm = expr.match(/^\$veil\s*\*\s*([0-9.]+)$/); + const vb = expr.match(/^\$veil$/); + const lm = expr.match(/^\$lift\s*\*\s*([0-9.]+)$/); + const lb = expr.match(/^\$lift$/); + const pm = expr.match(/^([0-9.]+)$/); + if (vm) { factor = parseFloat(vm[1]); over = 'veil'; } + else if (vb) { factor = 1; over = 'veil'; } + else if (lm) { factor = parseFloat(lm[1]); over = 'lift'; } + else if (lb) { factor = 1; over = 'lift'; } + else if (pm) { lit = parseFloat(pm[1]); } + stops.push({ kind: m[1], offset: parseFloat(m[2]), factor, lit, over }); + } + return stops; + }; + // 两套渐变:背后比目标亮(混黑)/ 比目标暗(混白)。两极共用, + // 差的只是目标灰度。 + // 锚点只能用代码:psCode 已经把注释行剥掉了(注释会喂绿断言)。 + const veilBranch = psCode.indexOf('if ($veil -gt 0.0) {'); + const veilElse = veilBranch < 0 ? -1 : psCode.indexOf('\n } else {', veilBranch); + const tintAfter = veilElse < 0 ? -1 : psCode.indexOf('$tintPath = $window.FindName', veilElse); + const stopsBlack = veilBranch < 0 || veilElse < 0 ? [] : tintStops(psCode.slice(veilBranch, veilElse)); + const stopsWhite = veilElse < 0 || tintAfter < 0 ? [] : tintStops(psCode.slice(veilElse, tintAfter)); + if (stopsBlack.length >= 4 && stopsWhite.length >= 4) { + out('PASS', `glass tint: the two tint gradients are readable (washed-black ${stopsBlack.length}, washed-white ${stopsWhite.length} stops)`); + } else { + out('FAIL', 'glass tint: the tint gradients could not be parsed -- the contrast model would be vacuous'); + } + + // 玻璃在某个渐变偏移上的实际颜色。 + const glassAt = (stops, offset, veil, lift, backdrop) => { + if (!stops.length) return backdrop; + const sorted = [...stops].sort((a, b) => a.offset - b.offset); + let s1 = sorted[0], s2 = sorted[sorted.length - 1]; + for (let i = 0; i < sorted.length - 1; i++) { + if (offset >= sorted[i].offset && offset <= sorted[i + 1].offset) { + s1 = sorted[i]; s2 = sorted[i + 1]; + break; + } + } + const span = s2.offset - s1.offset; + const t = span > 0 ? Math.max(0, Math.min(1, (offset - s1.offset) / span)) : 0; + const alphaOf = (s) => { + if (s.lit !== null) return s.lit; + if (s.factor === null) return 0; + if (s.over === 'lift') return s.factor * lift; + return s.factor * veil; + }; + const a1 = alphaOf(s1), a2 = alphaOf(s2); + const a = a1 + (a2 - a1) * t; + const c1 = s1.kind === 'white' ? 255 : 0; + const c2 = s2.kind === 'white' ? 255 : 0; + const c = c1 + (c2 - c1) * t; + return backdrop * (1 - a) + c * a; + }; + // 收敛模型:先定目标灰度,再反推要混多少。 + // 背后比目标亮 -> 混黑 veil = 1 - target / L + // 背后比目标暗 -> 混白 lift = (target - L) / (1 - L) + // 混完之后玻璃落在目标灰度上,跟背后是什么无关。 + const mixOf = (L, target) => (L > target + ? { veil: 1 - target / L, lift: 0 } + : { veil: 0, lift: (target - L) / (1 - L) }); + // sRGB -> WCAG + const lin = (c) => { c /= 255; return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; }; + const lum = (rgb) => 0.2126 * lin(rgb[0]) + 0.7152 * lin(rgb[1]) + 0.0722 * lin(rgb[2]); + const ratio = (a, b) => { + const la = lum(a), lb = lum(b); + const hi = Math.max(la, lb), lo = Math.min(la, lb); + return (hi + 0.05) / (lo + 0.05); + }; + const inkRgb = (hex8) => { + const a = parseInt(hex8.slice(1, 3), 16) / 255; + return { a, rgb: [parseInt(hex8.slice(3, 5), 16), parseInt(hex8.slice(5, 7), 16), parseInt(hex8.slice(7, 9), 16)] }; + }; + const over = (fg, bg) => fg.rgb.map((c, i) => fg.a * c + (1 - fg.a) * bg); + + const TITLE_OFF = 0.25; // 标题行在药丸里的纵向位置 + const BODY_OFF = 0.55; // 正文行 + const worst = { darkPrimary: Infinity, darkSecondary: Infinity, lightPrimary: Infinity, lightSecondary: Infinity }; + for (let i = 0; i <= 600; i++) { + const L = i / 600; + const backdrop = 255 * L; + // 深色极:玻璃收敛到目标灰度,不管背后是白纸还是黑终端。 + { + const m = mixOf(L, P.targetDark); + const stops = m.veil > 0 ? stopsBlack : stopsWhite; + const gTitle = glassAt(stops, TITLE_OFF, m.veil, m.lift, backdrop); + const gBody = glassAt(stops, BODY_OFF, m.veil, m.lift, backdrop); + const p = ratio(over(inkRgb(inkDark.Primary), gTitle), [gTitle, gTitle, gTitle]); + const s = Math.min( + ratio(over(inkRgb(inkDark.Secondary), gTitle), [gTitle, gTitle, gTitle]), + ratio(over(inkRgb(inkDark.Secondary), gBody), [gBody, gBody, gBody]), + ); + if (p < worst.darkPrimary) worst.darkPrimary = p; + if (s < worst.darkSecondary) worst.darkSecondary = s; + } + // 浅色极:同一个模型,目标灰度换成浅的那档。 + { + const m = mixOf(L, P.targetLight); + const stops = m.veil > 0 ? stopsBlack : stopsWhite; + const gTitle = glassAt(stops, TITLE_OFF, m.veil, m.lift, backdrop); + const gBody = glassAt(stops, BODY_OFF, m.veil, m.lift, backdrop); + const p = ratio(over(inkRgb(inkLight.Primary), gTitle), [gTitle, gTitle, gTitle]); + const s = Math.min( + ratio(over(inkRgb(inkLight.Secondary), gTitle), [gTitle, gTitle, gTitle]), + ratio(over(inkRgb(inkLight.Secondary), gBody), [gBody, gBody, gBody]), + ); + if (p < worst.lightPrimary) worst.lightPrimary = p; + if (s < worst.lightSecondary) worst.lightSecondary = s; + } + } + // 主文字:HIG 说 regular 变体要"maintain legibility of text", + // 4.5:1 是 WCAG AA 对正文的要求,这里按主文字执行。 + // 次级文字:Apple 自己在半透明材质上就是 2.7~3.4 这一档 + // (secondaryLabel 压在非纯白底上就是这个数),所以门槛取 + // 2.5:再低就真的看不见了。 + const PRIMARY_MIN = 4.5; + const SECONDARY_MIN = 2.5; + if (worst.darkPrimary >= PRIMARY_MIN) { + out('PASS', `glass contrast: the dark pole keeps the primary text at ${worst.darkPrimary.toFixed(2)}:1 over the worst backdrop (>= ${PRIMARY_MIN}:1)`); + } else { + out('FAIL', `glass contrast: the dark pole drops the primary text to ${worst.darkPrimary.toFixed(2)}:1 over the worst backdrop (needs ${PRIMARY_MIN}:1)`); + } + if (worst.lightPrimary >= PRIMARY_MIN) { + out('PASS', `glass contrast: the light pole keeps the primary text at ${worst.lightPrimary.toFixed(2)}:1 over the worst backdrop (>= ${PRIMARY_MIN}:1)`); + } else { + out('FAIL', `glass contrast: the light pole drops the primary text to ${worst.lightPrimary.toFixed(2)}:1 over the worst backdrop (needs ${PRIMARY_MIN}:1)`); + } + if (worst.darkSecondary >= SECONDARY_MIN) { + out('PASS', `glass contrast: the dark pole keeps the secondary text at ${worst.darkSecondary.toFixed(2)}:1 over the worst backdrop (>= ${SECONDARY_MIN}:1)`); + } else { + out('FAIL', `glass contrast: the dark pole drops the secondary text to ${worst.darkSecondary.toFixed(2)}:1 over the worst backdrop (needs ${SECONDARY_MIN}:1)`); + } + if (worst.lightSecondary >= SECONDARY_MIN) { + out('PASS', `glass contrast: the light pole keeps the secondary text at ${worst.lightSecondary.toFixed(2)}:1 over the worst backdrop (>= ${SECONDARY_MIN}:1)`); + } else { + out('FAIL', `glass contrast: the light pole drops the secondary text to ${worst.lightSecondary.toFixed(2)}:1 over the worst backdrop (needs ${SECONDARY_MIN}:1)`); + } + } + + // --- 6.9 大元素 = 更厚的材质 -------------------------------- + // HIG "Materials": "Liquid Glass appears more opaque in larger + // elements". 面板比药丸大得多,再透就成了一块挡板的玻璃。 + // + // 两极各有一处 Thick 分支,都要检查:只要有一极的系数掉到 1 + // 以下,那一极的面板就比药丸还透,玻璃的厚度感就没了。 + // 新模型里"更厚"不是把染色系数调大,而是把**目标灰度**推得更靠近 + // 材料自己的颜色:深色极更黑,浅色极更白。 + const thickDarkOk = /\$target\s*=\s*\$target\s*\*\s*\(1\.0\s*-\s*\$thickMix\)/.test(psCode); + const thickLightOk = /\$target\s*=\s*\$target\s*\+\s*\(1\.0\s*-\s*\$target\)\s*\*\s*\$thickMix/.test(psCode); + const thickOk = thickDarkOk && thickLightOk && P.thickDark > 0 && P.thickLight > 0; + if (thickOk) { + out('PASS', `glass depth: the panel is thicker than the pill in both poles (dark ${P.thickDark}, light ${P.thickLight})`); + } else { + out('FAIL', `glass depth: the panel is not thicker than the pill (dark branch ${thickDarkOk}, light branch ${thickLightOk}) -- the large element would stay as transparent as the pill`); + } + + // --- 6.10 XAML 里不能有硬编码的材质色 ---------------------- + // 材质层在 XAML 里只能是占位(Transparent),颜色一律运行时刷。 + const materialPaths = ['PillTint', 'PillGrain', 'PillRim', 'PillEdge', 'PanelTint', 'PanelGrain', 'PanelRim', 'PanelEdge']; + const badMaterial = []; + for (const name of materialPaths) { + const re = new RegExp('x:Name="' + name + '"[^>]*'); + const m = xaml.match(re); + if (!m) { badMaterial.push(`${name}:missing`); continue; } + const tag = m[0]; + if (!/Fill="Transparent"/.test(tag) || !/Stroke="Transparent"/.test(tag)) badMaterial.push(name); + } + if (badMaterial.length === 0) { + out('PASS', `glass layers: all ${materialPaths.length} material layers start uncoloured and are painted at runtime`); + } else { + out('FAIL', `glass layers: ${badMaterial.join(', ')} carry a baked-in colour instead of being painted at runtime`); + } + + // --- 6.10b 材质层不能参与测量 ------------------------------ + // + // Path 的 DesiredSize 来自 Data 的 Bounds,而材质的四条 Path 的 + // Data 正是"整块玻璃那么大"的矩形。放在 Grid 里它们就参与测量: + // 面板所在的是 Auto 行,于是面板高度 = max(上一次的材质矩形, + // 内容高度) —— 内容变矮时高度不降,材质矩形永远停在上一轮, + // 面板只增不减,最后黑成一条到底的长条。 + // + // Canvas 不测量子元素(DesiredSize 是 0)。材质层必须住在 + // Canvas 里:只画,不撑。 + const materialCanvas = (prefix) => { + const open = xaml.indexOf(``); + if (open < 0) return null; + const close = xaml.indexOf('', open); + if (close < 0) return null; + return { open, close }; + }; + const zones = { Pill: materialCanvas('Pill'), Panel: materialCanvas('Panel') }; + const outsideCanvas = []; + for (const name of materialPaths) { + const idx = xaml.indexOf(`x:Name="${name}"`); + const zone = name.startsWith('Pill') ? zones.Pill : zones.Panel; + if (idx < 0 || !zone || idx < zone.open || idx > zone.close) outsideCanvas.push(name); + } + if (outsideCanvas.length === 0) { + out('PASS', 'glass layout: every material path lives in a Canvas -- it paints without taking part in measuring'); + } else { + out('FAIL', `glass layout: ${outsideCanvas.join(', ')} are not inside a Canvas -- a Path measures as big as its Data, so the panel height sticks to the previous one`); + } + + // --- 6.10c 会话日志的遍历边界 ------------------------------ + // + // round-24 review (hetaoBackend):两个 scanner 以前都用 + // EnumerateXxx(..., AllDirectories),而微软的文档写明 + // AllDirectories **包含 reparse point**(符号链接、junction、 + // 挂载点): + // https://learn.microsoft.com/en-us/dotnet/api/system.io.searchoption + // + // sessions 目录底下放一个 junction,递归就会顺着它走进外面, + // 读到不属于这个用户的日志。所以源码里不能再有这种盲递归: + // 两个 scanner 都得走 scripts/lib/Safe-Traversal.ps1 那个有界的 + // 遍历 —— 它拒绝 reparse point,并且在打开之前验证 canonical + // containment。 + const safeLibPath = join(PLUGIN_ROOT, 'scripts', 'lib', 'Safe-Traversal.ps1'); + const safeLibExists = await exists(safeLibPath); + if (safeLibExists) { + out('PASS', 'traversal: the bounded traversal lib exists'); + } else { + out('FAIL', 'traversal: scripts/lib/Safe-Traversal.ps1 is missing -- the scanners have no bounded way to walk the sessions root'); + } + const blindWalk = []; + const notBounded = []; + if (safeLibExists) { + const safeText = await readFile(safeLibPath, 'utf8'); + const safeCode = safeText.split(/\r?\n/).filter((l) => !/^\s*#/.test(l)).join('\n'); + if (/AllDirectories/.test(safeCode)) blindWalk.push('scripts/lib/Safe-Traversal.ps1'); + if (!/TopDirectoryOnly/.test(safeCode)) blindWalk.push('scripts/lib/Safe-Traversal.ps1 (it does not walk one level at a time)'); + // 检查的是**调用点**,不是函数定义:定义里带着 ReparsePoint / + // Test-PathContained 这两个词,而调用点才是真正在把关的地方。 + if (!/Test-PathIsReparsePoint\s+\$d/.test(safeCode)) blindWalk.push('scripts/lib/Safe-Traversal.ps1 (it never rejects a reparse point)'); + if (!/Test-PathContained\s+\$rootFull\s+\$d/.test(safeCode)) blindWalk.push('scripts/lib/Safe-Traversal.ps1 (it never checks containment)'); + for (const rel of ['scripts/lib/Session-Tracker.ps1', 'mcode-status-detect.ps1']) { + const text = await readFile(join(PLUGIN_ROOT, ...rel.split('/')), 'utf8'); + const code = text.split(/\r?\n/).filter((l) => !/^\s*#/.test(l)).join('\n'); + if (/AllDirectories/.test(code)) blindWalk.push(rel); + if (!/Get-SafeSessionEntries/.test(code)) notBounded.push(rel); + } + } + if (blindWalk.length === 0 && safeLibExists) { + out('PASS', 'traversal: no scanner walks the sessions root with a blind AllDirectories recursion'); + } else { + out('FAIL', `traversal: ${blindWalk.join(', ')} still recurse with AllDirectories -- it follows reparse points out of the sessions root`); + } + if (notBounded.length === 0 && safeLibExists) { + out('PASS', 'traversal: both scanners go through the bounded traversal'); + } else { + out('FAIL', `traversal: ${notBounded.join(', ')} do not use Get-SafeSessionEntries`); + } + + // --- 6.11 每块面各判各的极 ---------------------------------- + // HIG "Materials": 每块材质各自调整明度来保住可读性。药丸和展开 + // 面板背后可能差得很远(药丸压着一段亮色标题栏,面板底下却是一 + // 片深色编辑器),所以极性和墨色必须**按面拆开**。 + // + // 共用一份极性的后果真机见过:面板判出浅色极把全局翻了,药丸就 + // 变成"暗玻璃 + 深色字",看着像渲染坏了 —— 而且两块面每 100ms + // 互相翻一次,整个 UI 在浅色/深色之间闪。 + { + const hasPanelPole = /\$script:glassPanelPole\s*=\s*'dark'/.test(psCode); + if (hasPanelPole) { + out('PASS', 'glass poles: the panel keeps a pole of its own, independent of the pill'); + } else { + out('FAIL', 'glass poles: the panel has no pole of its own ($script:glassPanelPole) -- both surfaces would share one polarity, and the one that disagrees would end up unreadable'); + } + + // 判极的函数必须是纯的:迟滞相对"这块面自己的极"判断。 + // 一旦它去读全局极性,两块面就又绑在一起了。 + const resolverBody = (psCode.match(/function Resolve-GlassPole \{[\s\S]*?\n\}/) || [''])[0]; + const resolverPure = /param\(\[double\]\$L, \[string\]\$Current\)/.test(resolverBody) + && !/\$script:glassPole\b/.test(resolverBody) + && !/\$script:glassPanelPole\b/.test(resolverBody); + if (resolverPure) { + out('PASS', 'glass poles: the pole resolver is pure -- hysteresis is judged against the surface it is handed'); + } else { + out('FAIL', 'glass poles: Resolve-GlassPole reads or writes a global pole -- the pill and the panel would keep flipping each other'); + } + + // 两块面必须各写各的极。 + const writesPill = /\$script:glassPole\s*=\s*\$pole/.test(psCode); + const writesPanel = /\$script:glassPanelPole\s*=\s*\$pole/.test(psCode); + const picksPerPrefix = /\$isPanel\s*=\s*\(\$Prefix\s+-eq\s+'Panel'\)/.test(psCode) + && /Resolve-GlassPole\s+\$L\s+\$curPole/.test(psCode); + if (writesPill && writesPanel && picksPerPrefix) { + out('PASS', 'glass poles: the material picks the pole per surface (pill and panel each store their own)'); + } else { + out('FAIL', `glass poles: the material does not pick a pole per surface (pill write ${writesPill}, panel write ${writesPanel}, per-prefix branch ${picksPerPrefix})`); + } + + // 列表行画在面板上,字的墨色必须跟面板的极走。 + const rowBody = (psCode.match(/function Add-SessionRow[\s\S]*?\n\}/) || [''])[0]; + const rowsUsePanelInk = /Get-PanelInk/.test(rowBody) + && /Get-PanelStateMap/.test(rowBody) + && !/Get-GlassInk/.test(rowBody) + && !/Get-GlassStateMap/.test(rowBody); + if (rowsUsePanelInk) { + out('PASS', 'glass poles: the session rows take their ink and state colours from the panel they are drawn on'); + } else { + out('FAIL', 'glass poles: the session rows still take their colours from the global pole -- a dark panel would get dark text'); + } + + // 药丸的字跟药丸自己的极走,不能被面板的极带偏。 + const inkBody = (psCode.match(/function Update-GlassInk \{[\s\S]*?\n\}/) || [''])[0]; + const pillInkOwn = /Get-GlassInk/.test(inkBody) && !/Get-PanelInk/.test(inkBody); + if (pillInkOwn) { + out('PASS', 'glass poles: the pill text takes its ink from the pill pole, not the panel pole'); + } else { + out('FAIL', 'glass poles: the pill text is coloured from the panel pole -- the pill and the panel would fight over one ink set'); + } + + // 颗粒笔刷同理:暗玻璃白点、亮玻璃黑点,两块面各用各的。 + const grainPerSurface = /Update-GlassGrain\s+'PillGrain'\s+\$script:glassPole/.test(psCode) + && /Update-GlassGrain\s+'PanelGrain'\s+\$script:glassPanelPole/.test(psCode); + if (grainPerSurface) { + out('PASS', 'glass poles: the grain brush follows each surface pole (white specks on dark glass, black on light)'); + } else { + out('FAIL', 'glass poles: the grain brush is not chosen per surface -- one of the two glass bodies gets the wrong speck colour'); + } + + // 亮度日志也按面分开。 + // + // 两块面共用一个槽位时,亮度行只在变化超过 0.12 时才记,两块面 + // 轮流往同一个槽位写,后写的那面几乎永远落不下自己的一行 —— + // "面板为什么变白了"就成了查不出来的事(面板换了极,日志里只剩 + // 药丸的亮度)。日志是排查这类问题的唯一入口,不能自己瞎掉。 + const lumPerSurface = /\$script:glassLoggedLum\s*=\s*@\{\}/.test(psCode) + && /\$null -eq \$script:glassLoggedLum\[\$Prefix\]/.test(psCode); + if (lumPerSurface) { + out('PASS', 'glass poles: each surface logs its own luminance (the panel would never get a brightness line of its own)'); + } else { + out('FAIL', 'glass poles: the luminance log slot is shared between the two surfaces -- the panel would almost never get a brightness line of its own'); + } + } + + // --- 6.12 收敛到目标灰度("统一黑色")------------------------ + // + // HIG "Materials": regular 变体 "blurs and **adjusts the luminosity** + // of background content to maintain legibility of text"。所以算法 + // 必须是"先定目标灰度,再反推混多少",而不是"压固定比例的黑"。 + // + // 不这么做的后果真机见过:白底上玻璃停在中灰(字节 106 ≈ #6A6A6A), + // 深色字压上去就是一团脏灰 —— 用户嫌它"灰突突"。 + { + const converges = /\$veil\s*=\s*1\.0\s*-\s*\(\$target\s*\/\s*\$L\)/.test(psCode) + && /\$lift\s*=\s*\(\$target\s*-\s*\$L\)\s*\/\s*\(1\.0\s*-\s*\$L\)/.test(psCode); + if (converges) { + out('PASS', 'glass convergence: the glass mixes towards a target grey instead of a fixed black wash'); + } else { + out('FAIL', 'glass convergence: the material does not solve for a target grey -- the glass lands on a mid grey over a bright backdrop'); + } + + // 深色极的目标必须落在"真黑"那一档:macOS 那个 Dynamic Island + // 是 #1C1C1E(字节 28 ≈ 0.11),再深就糊成一团分不出玻璃。 + const darkTarget = P.targetDark; + if (darkTarget !== null && darkTarget >= 0.09 && darkTarget <= 0.15) { + out('PASS', `glass convergence: the dark pole converges on the island black (target ${darkTarget}, ~#${Math.round(255 * darkTarget).toString(16).padStart(2, '0').toUpperCase()})`); + } else { + out('FAIL', `glass convergence: the dark pole target ${darkTarget} is not the island black (0.09..0.15) -- the glass would read as grey, not as a black slab`); + } + + // 默认必须是统一的黑玻璃:浅色极在白底上"灰突突"就是它惹的。 + const defaultIsDark = /materialPole\s*=\s*'dark'/.test(psCode); + if (defaultIsDark) { + out('PASS', 'glass convergence: the default material is the unified black glass'); + } else { + out('FAIL', 'glass convergence: the default material is not the black glass -- the island would still switch to a grey material over a bright backdrop'); + } + + // 参数名不许和局部名撞车。 + // + // PowerShell 的变量名不区分大小写,而且参数的类型约束还在: + // 参数是 [bool]$Thick,函数体里再写 $thick = 0.25 会被强制转成 + // $true,接着 (1.0 - $thick) 算出 0,目标灰度被乘成 0 —— 玻璃 + // 成了纯黑(veil 恒等于 1)。 + // + // 这类坑静态断言看不出来(数值都"对",只是被转换了),所以只能 + // 把"参数名和局部名不撞车"这条纪律本身锁住。 + const applyStart = psCode.indexOf('function Apply-GlassMaterial {'); + const applyEnd = psCode.indexOf('\nfunction ', applyStart + 10); + const applyBody = applyStart < 0 ? '' : psCode.slice(applyStart, applyEnd < 0 ? undefined : applyEnd); + const thickIsTyped = /param\([^)]*\[bool\]\$Thick\)/.test(applyBody); + const thickShadowed = /\$thick(?![\w])\s*=/i.test(applyBody); + if (thickIsTyped && !thickShadowed) { + out('PASS', 'glass convergence: the thickness factor does not shadow the typed parameter ($Thick)'); + } else { + out('FAIL', 'glass convergence: the thickness factor shadows the [bool]$Thick parameter -- PowerShell would coerce it and the target grey would collapse to 0'); + } + } + } + } + finish(); }; diff --git a/plugins/antianqi/mcode-island/scripts/test-material-injection.mjs b/plugins/antianqi/mcode-island/scripts/test-material-injection.mjs new file mode 100644 index 00000000..9eb0573a --- /dev/null +++ b/plugins/antianqi/mcode-island/scripts/test-material-injection.mjs @@ -0,0 +1,488 @@ +#!/usr/bin/env node +// Negative-injection harness for the Liquid Glass material checks. +// +// Each case mutates exactly ONE line of the widget source, runs the smoke +// suite and asserts that the EXPECTED check flips to FAIL. Then the line is +// restored. At the end the suite must be green again. +// +// The point: a check that cannot go red proves nothing. Every guard added to +// smoke.mjs has to survive this table -- a green suite that stays green no +// matter what you break is decoration, not a test. +// +// node scripts/test-material-injection.mjs +// +// It mutates a COPY of the plugin, never the working tree. +// +// The old version rewrote plugins/antianqi/mcode-island/mcode-island.ps1 in +// place. `node --test` runs every suite in the repository in PARALLEL, so a +// sibling suite that read the widget while this harness was writing it saw +// a HALF-WRITTEN file -- writeFile truncates before it writes -- and the +// reader reported "function Build-DisplayMessage not found" for a function +// that is in the file. That is a race, not a test failure, and it took the +// ubuntu CI job down. Working on a copy removes the race for good: the +// checked-in sources are never open for writing, so a parallel reader always +// sees a complete file, and an interrupted run cannot leave the tree +// half-mutated. + +import { readFile, writeFile } from 'node:fs/promises'; +import { mkdtempSync, cpSync, rmSync, existsSync, mkdirSync } from 'node:fs'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { join, dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { tmpdir } from 'node:os'; + +const exec = promisify(execFile); +const ROOT = dirname(dirname(fileURLToPath(import.meta.url))); +const REPO_ROOT = resolve(ROOT, '..', '..', '..'); + +// The copy mirrors the repository layout on purpose: smoke.mjs imports the +// shared validator from the repository root, five levels up from its own +// file (../../../../../scripts/lib/validation.mjs). A flat temp directory +// would resolve that import to the wrong place and silently drop the +// hooks.json schema check from the copy's run. +const TMP = mkdtempSync(join(tmpdir(), 'mcode-island-injection-')); +const TMP_PLUGIN = join(TMP, 'plugins', 'antianqi', 'mcode-island'); +cpSync(ROOT, TMP_PLUGIN, { recursive: true }); +const VALIDATOR = join(REPO_ROOT, 'scripts', 'lib', 'validation.mjs'); +if (existsSync(VALIDATOR)) { + mkdirSync(join(TMP, 'scripts', 'lib'), { recursive: true }); + cpSync(VALIDATOR, join(TMP, 'scripts', 'lib', 'validation.mjs')); +} + +// The working-tree sources the harness would rewrite if it still worked in +// place. The watcher below proves that it never touches them. +const REAL_WIDGET = join(ROOT, 'mcode-island.ps1'); +const REAL_SAFE = join(ROOT, 'scripts', 'lib', 'Safe-Traversal.ps1'); +const REAL_TRACKER = join(ROOT, 'scripts', 'lib', 'Session-Tracker.ps1'); +const REAL_DETECTOR = join(ROOT, 'mcode-status-detect.ps1'); + +const WIDGET = join(TMP_PLUGIN, 'mcode-island.ps1'); +const SMOKE = join(TMP_PLUGIN, 'scripts', 'smoke.mjs'); +const SAFE = join(TMP_PLUGIN, 'scripts', 'lib', 'Safe-Traversal.ps1'); +const TRACKER = join(TMP_PLUGIN, 'scripts', 'lib', 'Session-Tracker.ps1'); +const DETECTOR = join(TMP_PLUGIN, 'mcode-status-detect.ps1'); + +const CASES = [ + { + // 这条守的是"浅色极在**暗**背景下也要读得清"。老模型里浅色极只在 + // 亮背景才成立(对比度只在那半边复算),收敛模型之后两极在所有背景 + // 上都可读,这条不能丢。 + name: 'the pale glass stops being readable over a dark backdrop', + file: WIDGET, + find: 'New-GlassStop $white 0.68 ($lift * 0.76)', + replace: 'New-GlassStop $white 0.68 ($lift * 0.05)', + expect: 'glass contrast: the light pole drops the secondary text', + }, + { + name: 'the dark target grey is too bright -> white text on the glass stops being readable', + file: WIDGET, + find: '$script:glassTargetByteDark = 0.115', + replace: '$script:glassTargetByteDark = 0.55', + expect: 'glass contrast: the dark pole drops the primary text', + }, + { + name: 'the dark target is no longer the island black -> the glass reads as a grey plate again', + file: WIDGET, + find: '$script:glassTargetByteDark = 0.115', + replace: '$script:glassTargetByteDark = 0.30', + expect: 'glass convergence: the dark pole target', + }, + { + name: 'the light target collapses -> the pale glass stops being readable', + file: WIDGET, + find: '$script:glassTargetByteLight = 0.90', + replace: '$script:glassTargetByteLight = 0.30', + expect: 'glass contrast: the light pole drops the primary text', + }, + { + name: 'the convergence formula is gone -> the glass falls back to a fixed black wash', + file: WIDGET, + find: '$veil = 1.0 - ($target / $L)', + replace: '$veil = 0.85', + expect: 'glass convergence: the material does not solve for a target grey', + }, + { + name: 'the default material is no longer the unified black -> a bright backdrop gets the grey one', + file: WIDGET, + find: "materialPole = 'dark'", + replace: "materialPole = 'auto'", + expect: 'glass convergence: the default material is not the black glass', + }, + { + name: 'light-pole primary ink turned white -> white on a light glass', + file: WIDGET, + find: "Primary = '#D9000000' # macOS label: 85% 黑", + replace: "Primary = '#FFFFFFFF' # macOS label: 85% 黑", + expect: 'glass contrast: the light pole drops the primary text', + }, + { + name: 'self-exclusion dropped -> the capture blurs the widget itself', + file: WIDGET, + find: 'SetWindowDisplayAffinity($hwnd, 0x00000011)', + replace: 'SetWindowDisplayAffinity($hwnd, 0x00000000)', + expect: 'glass capture: the widget does not exclude itself', + }, + { + name: 'offscreen render without layout -> the blurred backdrop renders black', + file: WIDGET, + find: ' $img.Measure((New-Object System.Windows.Size($smallW, $smallH)))\n', + replace: '', + expect: 'glass capture: the offscreen image is rendered without a prior Measure/Arrange', + }, + { + name: 'shape clip removed -> the corners render square', + file: WIDGET, + find: ' $glass.Clip = $geo', + replace: ' # $glass.Clip = $geo', + expect: 'glass shape: the glass surface is not clipped by a rounded path', + }, + { + name: 'blur radius not scaled for physical pixels -> the pill turns into a flat plate', + file: WIDGET, + find: ' $scaleDown = 4', + replace: ' $scaleDown = 1', + expect: 'glass blur: the effective blur is', + }, + { + name: 'grain turned visible -> the frosted surface turns speckled', + file: WIDGET, + find: '$target.Fill = $grain; $target.Opacity = 0.015', + replace: '$target.Fill = $grain; $target.Opacity = 0.050', + expect: 'glass grain: the grain opacity is not a small value', + }, + { + name: 'state table twin loses a state -> one state keeps the wrong colour on the light pole', + file: WIDGET, + find: " error = @{ dot='#FFD70015'; ring='#FFD70015'; label='mcode · 出错'; icon='✕' }\n}\n\n$familyMapLight", + replace: "}\n\n$familyMapLight", + expect: 'glass colour maps: the state table and its light-pole twin cover different states', + }, + { + name: 'two family colours collapsed -> they read as the same colour on the pill', + file: WIDGET, + find: " write = '#FFA88700' # edit / write → 琥珀", + replace: " write = '#FF8E8E93' # edit / write → 琥珀", + expect: 'glass colour maps: light pole family colours', + }, + { + name: 'the thickness factor shadows the typed parameter -> the target grey collapses to zero', + file: WIDGET, + find: '$thickMix = $script:glassThickDark', + replace: '$thick = $script:glassThickDark', + expect: 'glass convergence: the thickness factor shadows the [bool]$Thick parameter', + }, + { + name: 'panel no longer thicker than the pill -> a big panel stays as transparent as the pill', + file: WIDGET, + find: '$target = $target * (1.0 - $thickMix)', + replace: '$target = $target * (1.0 + $thickMix)', + expect: 'glass depth: the panel is not thicker than the pill', + }, + { + name: 'a material layer gets a baked-in colour -> the material stops taking its colour from behind', + file: WIDGET, + find: '', + replace: '', + expect: 'glass layers:', + }, + { + name: 'the legacy hardcoded tint comes back -> a tinted plate is not glass', + file: WIDGET, + find: '', + replace: '', + expect: 'glass material: still carries the legacy hardcoded tint', + }, + { + name: 'pole hysteresis removed -> the material flickers at the threshold', + file: WIDGET, + find: '$script:glassPoleLightOn = 0.60', + replace: '$script:glassPoleLightOn = 0.58', + expect: 'glass pole: the light switch', + }, + { + name: 'the panel loses its own pole -> both surfaces share one polarity', + file: WIDGET, + find: "$script:glassPanelPole = 'dark'", + replace: "$script:glassPanelPole = 'auto'", + expect: 'glass poles: the panel has no pole of its own', + }, + { + name: 'the pole resolver reads the global pole -> the two surfaces flip each other', + file: WIDGET, + find: 'param([double]$L, [string]$Current)', + replace: 'param([double]$L, [string]$Current2)', + expect: 'glass poles: Resolve-GlassPole reads or writes a global pole', + }, + { + name: 'the panel writes the pill pole -> one surface overwrites the other', + file: WIDGET, + find: '$script:glassPanelPole = $pole', + replace: '$script:glassPole = $pole', + expect: 'glass poles: the material does not pick a pole per surface', + }, + { + name: 'the session rows go back to the global ink -> a dark panel gets dark text', + file: WIDGET, + find: ' $ink = Get-PanelInk', + replace: ' $ink = Get-GlassInk', + expect: 'glass poles: the session rows still take their colours from the global pole', + }, + { + name: 'the pill text is coloured from the panel pole -> the pill and the panel fight over one ink set', + file: WIDGET, + find: ' $ink = Get-GlassInk\n try {', + replace: ' $ink = Get-PanelInk\n try {', + expect: 'glass poles: the pill text is coloured from the panel pole', + }, + { + name: 'the panel grain follows the pill pole -> one glass body gets the wrong speck colour', + file: WIDGET, + find: "Update-GlassGrain 'PanelGrain' $script:glassPanelPole", + replace: "Update-GlassGrain 'PanelGrain' $script:glassPole", + expect: 'glass poles: the grain brush is not chosen per surface', + }, + { + name: 'the luminance log slot goes back to one shared value -> the panel never gets a brightness line', + file: WIDGET, + find: '$script:glassLoggedLum = @{}', + replace: '$script:glassLoggedLum = $null', + expect: 'glass poles: the luminance log slot is shared between the two surfaces', + }, + { + name: 'the luminance log loses its per-surface key -> both surfaces overwrite one slot again', + file: WIDGET, + find: '$null -eq $script:glassLoggedLum[$Prefix] -or [Math]::Abs($L - $script:glassLoggedLum[$Prefix])', + replace: '$null -eq $script:glassLoggedLum -or [Math]::Abs($L - $script:glassLoggedLum)', + expect: 'glass poles: the luminance log slot is shared between the two surfaces', + }, + { + // 注释没闭合时,旧写法 replace(//g,'') 会把没配对的 + // 配上,那就不是"没闭合"了。 + find: '', + replace: ' " 也是合法的注释结束标记。只认 "-->" 的过滤器会把它 + // 当成没闭合,CodeQL 的 js/bad-html-filtering-regexp 正是判这个。 + name: 'the strip only knows "-->" -> a comment ended by "--!>" stays open', + file: SMOKE, + find: ' const m = /--!?>/.exec(s.slice(from));', + replace: ' const m = /-->/.exec(s.slice(from));', + expect: 'xaml comments: the strip does not recognise every HTML comment end tag', + }, + { + // 结束标记有两个长度("-->" 3 个字符,"--!>" 4 个),推进量按 3 走会 + // 在 "--!>" 后面多留一个字符。这条就是当时真实犯的那个 bug。 + name: 'the strip advances by the wrong length -> a character survives the comment', + file: SMOKE, + find: ' i = close.at + close.len; // "-->" is 3 chars, "--!>" is 4', + replace: ' i = close.at + 3; // "-->" is 3 chars, "--!>" is 4', + expect: 'xaml comments: the strip does not recognise every HTML comment end tag', + }, + { + // 材质层放回 Grid 里:Path 的 DesiredSize 来自 Data 的 Bounds, + // 也就是"整块玻璃那么大"。于是它参与测量,面板高度被撑到上一轮, + // 内容变矮时高度不降 —— 黑条只会越来越长。 + name: 'the pill material paths go back into the Grid -> they take part in measuring again', + file: WIDGET, + find: ' ', + replace: ' ', + expect: 'glass layout:', + }, + { + name: 'the panel material paths go back into the Grid -> the panel height sticks to the previous one', + file: WIDGET, + find: ' ', + replace: ' ', + expect: 'glass layout:', + }, + { + // review 第 4 条要的是"有界的数据"。条数上限管不了文件大小:每条会话 + // 还带着 titles 历史,dismissed 那张表也没有条数上限。写盘前要按字节 + // 砍一遍 —— 这一条把那个砍法去掉,文件就又会长回去。 + name: 'the sessions.json byte cap goes away -> the file grows with the title history', + file: TRACKER, + find: " $json = Limit-SessionsJson $items $script:dismissedSessions $script:MAX_SESSIONS_JSON_BYTES", + replace: " $json = [PSCustomObject]@{ sessions = $items; dismissed = $script:dismissedSessions; ts = (Get-Date).ToString('o') } | ConvertTo-Json -Compress -Depth 6", + expect: 'Write-SessionsFile does not apply the byte cap', + }, { + // round-24 review (hetaoBackend):AllDirectories 会跟着 reparse point + // 走进 sessions root 之外。盲递归回来,遍历就又不设防了。 + name: 'the tracker goes back to a blind AllDirectories recursion', + file: TRACKER, + find: " foreach ($dir in (Get-SafeSessionEntries -Root $root -Pattern '*session_*')) {", + replace: " foreach ($dir in [System.IO.Directory]::EnumerateDirectories($root, '*session_*', 'AllDirectories')) {", + expect: 'traversal: scripts/lib/Session-Tracker.ps1 still recurse with AllDirectories', + }, + { + name: 'the detector goes back to a blind AllDirectories recursion', + file: DETECTOR, + find: " foreach ($f in (Get-SafeSessionEntries -Root $sessionsRoot -Pattern '*.jsonl' -Files)) {", + replace: " foreach ($f in [System.IO.Directory]::EnumerateFiles($sessionsRoot, '*.jsonl', 'AllDirectories')) {", + expect: 'traversal: mcode-status-detect.ps1 still recurse with AllDirectories', + }, + { + name: 'the traversal stops rejecting reparse points -> a junction is followed out of the root', + file: SAFE, + find: ' if (Test-PathIsReparsePoint $d) { continue }', + replace: ' if ($false) { continue }', + expect: 'traversal: scripts/lib/Safe-Traversal.ps1 (it never rejects a reparse point)', + }, + { + name: 'the traversal stops checking containment -> a candidate outside the root is opened', + file: SAFE, + find: ' if (-not (Test-PathContained $rootFull $d)) { continue }', + replace: ' if ($false) { continue }', + expect: 'traversal: scripts/lib/Safe-Traversal.ps1 (it never checks containment)', + }, + { + // 键盘可达这条断言有四个分支,每一个都得能被抓住:抓不住就等于没写。 + name: 'the dismiss mark stops being focusable -> Tab can no longer reach it', + file: WIDGET, + find: ' $xMark.Focusable = $true', + replace: ' $xMark.Focusable = $false', + expect: 'the dismiss mark is not focusable', + }, + { + name: 'the keyboard handler is never attached -> Enter and Space do nothing', + file: WIDGET, + find: ' $xMark.Add_KeyDown({', + replace: ' $xMark.Add_KeyDownX({', + expect: 'the dismiss mark has no keyboard handler', + }, + { + name: 'the keyboard handler no longer dismisses -> the row survives the key press', + file: WIDGET, + find: ' Dbg ("session dismissed by keyboard: " + $key + " " + $id)\n Invoke-SessionDismiss $id', + replace: ' Dbg ("session dismissed by keyboard: " + $key + " " + $id)', + expect: 'the keyboard handler does not dismiss the row', + }, + { + name: 'the keyboard handler drops Space -> half of the keys do nothing', + file: WIDGET, + find: "if ($key -ne 'Return' -and $key -ne 'Enter' -and $key -ne 'Space') { return }", + replace: "if ($key -ne 'Return' -and $key -ne 'Enter') { return }", + expect: 'the keyboard handler ignores Space', + }, + { + name: 'the keyboard handler drops Return -> the main Enter key does nothing', + file: WIDGET, + find: "if ($key -ne 'Return' -and $key -ne 'Enter' -and $key -ne 'Space') { return }", + replace: "if ($key -ne 'Enter' -and $key -ne 'Space') { return }", + expect: 'the keyboard handler ignores the Enter key', + }, +]; + +async function runSmoke() { + try { + const { stdout } = await exec('node', [SMOKE], { cwd: TMP_PLUGIN, maxBuffer: 64 * 1024 * 1024 }); + return { code: 0, out: stdout }; + } catch (e) { + return { code: e.code ?? 1, out: `${e.stdout || ''}${e.stderr || ''}` }; + } +} + +// --- the working tree must stay byte-identical while the harness runs ---- +// +// The regression test for the ubuntu CI failure: `node --test` runs every +// suite in the repository at once, and a sibling suite that reads a file +// while this harness writes it sees a half-written file. The watch below +// re-reads the working-tree sources for the whole run and fails if any of +// them moved. +const WATCHED = [REAL_WIDGET, REAL_SAFE, REAL_TRACKER, REAL_DETECTOR]; +const baseline = new Map(); +for (const p of WATCHED) baseline.set(p, await readFile(p, 'utf8')); + +let violation = null; +let watching = true; +const watcher = (async () => { + while (watching) { + for (const p of WATCHED) { + let now; + try { + now = await readFile(p, 'utf8'); + } catch (e) { + violation = `${p} (${e.message})`; + return; + } + if (now !== baseline.get(p)) { + violation = p; + return; + } + } + await new Promise((r) => setTimeout(r, 20)); + } +})(); + +let bad = 0; +for (const c of CASES) { + const original = await readFile(c.file, 'utf8'); + if (!original.includes(c.find)) { + console.log(`[SKIP] ${c.name} -- anchor not found: ${JSON.stringify(c.find.slice(0, 60))}`); + bad++; + continue; + } + await writeFile(c.file, original.replace(c.find, c.replace), 'utf8'); + try { + const res = await runSmoke(); + const hit = res.out.split(/\r?\n/).filter((l) => l.includes(c.expect) && l.includes('FAIL')); + const otherFails = res.out.split(/\r?\n/).filter((l) => l.startsWith('[FAIL]')); + if (hit.length === 0) { + console.log(`[FAIL] ${c.name} -- expected a FAIL on "${c.expect}", got none`); + console.log(` other failures: ${otherFails.length ? otherFails.join(' | ') : 'none'}`); + bad++; + } else { + console.log(`[OK ] ${c.name} -> ${hit[0].trim().slice(0, 150)}`); + } + } finally { + await writeFile(c.file, original, 'utf8'); + } +} + +const final = await runSmoke(); +const summary = (final.out.match(/summary: .*/) || ['(no summary)'])[0]; +const fails = final.out.split(/\r?\n/).filter((l) => l.startsWith('[FAIL]')); +console.log('-'.repeat(60)); +console.log(`restored: ${summary}`); +if (fails.length) { + console.log(`leftover failures:\n${fails.join('\n')}`); + bad++; +} + +watching = false; +await watcher; +if (violation) { + console.log(`[FAIL] the injection harness never rewrites the working tree -- ${violation} changed while the harness ran`); + bad++; +} else { + console.log(`[OK ] the injection harness never rewrites the working tree`); +} + +try { rmSync(TMP, { recursive: true, force: true }); } catch { /* best effort */ } +if (bad) { + console.log(`INJECTION HARNESS: ${bad} problem(s)`); + process.exit(1); +} +console.log(`INJECTION HARNESS: all ${CASES.length} injections behaved as expected`); diff --git a/plugins/antianqi/mcode-island/scripts/test-session-tracker.ps1 b/plugins/antianqi/mcode-island/scripts/test-session-tracker.ps1 new file mode 100644 index 00000000..e85c8cbe --- /dev/null +++ b/plugins/antianqi/mcode-island/scripts/test-session-tracker.ps1 @@ -0,0 +1,760 @@ +# Behavioral tests for the multi-session tracker. +# +# Emits TAP-ish lines the node suite parses: +# ok N - +# not ok N - : +# +# The tracker lives in a lib that expects the detector's scope +# ($sessionsRoot, $configDir, the state constants, Get-LastJsonLine, +# Test-IsAskUser, the store lock). This harness assembles that scope +# against a FIXTURE session tree, so the assertions run against the real +# functions -- not a re-implementation of them. + +$ErrorActionPreference = 'Stop' + +# The plugin root comes from this script's own location, not from +# $env:USERPROFILE. CI checks the repo out to D:\a\\, so a +# path built from the user profile does not exist there -- the suite +# then dies before its first check and reports "0 passed", which reads +# like "nothing to test" rather than "the harness is broken". +$pluginRoot = Split-Path -Parent $PSScriptRoot +$fixture = Join-Path ([System.IO.Path]::GetTempPath()) ('sess-fixture-' + [guid]::NewGuid().ToString('N').Substring(0, 8)) +$sessionsRoot = Join-Path $fixture 'sessions' +$configDir = Join-Path $fixture 'config' +$dayDir = Join-Path (Join-Path (Join-Path $sessionsRoot '2026') '10') '23' +New-Item -ItemType Directory -Path $dayDir -Force | Out-Null +New-Item -ItemType Directory -Path $configDir -Force | Out-Null + +# The store lib resolves its lock file under %APPDATA%. Point it at the +# fixture: the test must not touch the real store, and the ubuntu CI job +# has no APPDATA at all (Join-Path $null throws there). +$env:APPDATA = Join-Path $fixture 'appdata' +New-Item -ItemType Directory -Path $env:APPDATA -Force | Out-Null + +# --- the scope the lib expects ------------------------------------------- +$S_USER = 'user'; $S_ASSISTANT = 'assistant'; $S_TOOLRESULT = 'toolResult' +$S_TOOLCALL = 'toolCall'; $FNAME_LEDGER = 'ledger.jsonl'; $FNAME_MESSAGES = 'messages.jsonl' +$S_THINKING = 'thinking' +# The tracker reads content blocks by type now, exactly like the +# detector's Infer-State does. Without these two the text/thinking blocks +# are never recognised -- and the state comes out wrong. +$S_TEXT = 'text'; $S_THINK_BLK = 'thinking' +function Log-Line($msg) { } +function Test-IsAskUser($name) { return ([string]$name) -eq 'AskUserQuestion' } + +# The detector's tail reader and ask_user test, verbatim. +$detect = [System.IO.File]::ReadAllText((Join-Path $pluginRoot 'mcode-status-detect.ps1')) +function Extract-Function([string]$src, [string]$name) { + $m = [regex]::Match($src, "(?ms)^function\s+$([regex]::Escape($name))\s*\(.*?^}") + if (-not $m.Success) { throw "function $name not found" } + return $m.Value +} +Invoke-Expression (Extract-Function $detect 'Get-LastJsonLine') +Invoke-Expression (Extract-Function $detect 'Get-ToolKey') +Invoke-Expression (Extract-Function $detect 'Test-IsAskUser') + +# The store lib (the lock) and the tracker lib. +# +# Safe-Traversal.ps1 has to be loaded BEFORE Session-Tracker.ps1: the tracker +# refuses to walk the sessions tree without it (fail closed), and the tracker +# lib checks for it at the point of use, so the order here is what a reader +# has to know. +. (Join-Path (Join-Path $pluginRoot 'scripts') (Join-Path 'lib' 'Status-Store.ps1')) +. (Join-Path (Join-Path $pluginRoot 'scripts') (Join-Path 'lib' 'Safe-Traversal.ps1')) +. (Join-Path (Join-Path $pluginRoot 'scripts') (Join-Path 'lib' 'Session-Tracker.ps1')) + +# 脱敏用的是真的那个 lib(scripts/lib/Protect-Text.ps1):生产里 detector 也是 +# 先 dot-source 它、再 dot-source tracker。用一个只认 `sk-` 的替身只能证明 +# "调用发生了",证明不了真正覆盖哪些形状 —— 而 review 要的正是覆盖面: +# 标题和预览里出现 JWT / Bearer / JSON token / kv 密码时,都不能漏。 +. (Join-Path (Join-Path $pluginRoot 'scripts') (Join-Path 'lib' 'Protect-Text.ps1')) + +# 功能用例一次会造二十来个会话,而生产默认上限就是 20 —— 上限本身在第 14 组 +# 里显式设定验证。这里先放宽,免得上限把别组的夹具淘汰掉,变成"别的断言 +# 失败、原因却是上限"。默认值先记下来,第 14 组断言它是个真实的界。 +$defaultMax = $script:MAX_SESSIONS +$script:MAX_SESSIONS = 1000 + +# 读日志的计数器:审核第 1 条要断言"过期的会话的日志一次都没被读"。 +# tracker 调 Get-LastJsonLine 是运行期解析,所以这里重新定义一遍就会生效。 +$script:readFiles = New-Object System.Collections.Generic.List[string] +$script:realGetLastJsonLine = ${function:Get-LastJsonLine} +function Get-LastJsonLine($file) { + $script:readFiles.Add([string]$file) + return (& $script:realGetLastJsonLine $file) +} + +# --- assertions ---------------------------------------------------------- +$script:pass = 0; $script:fail = 0; $script:n = 0 +function Check([string]$name, [bool]$cond, [string]$why = '') { + $script:n++ + if ($cond) { $script:pass++; Write-Output "ok $script:n - $name" } + else { $script:fail++; Write-Output "not ok $script:n - ${name}: $why" } +} + +# Make a session directory with the given message lines, and return its path. +function New-Session([string]$id, [string[]]$lines, [int]$ageMinutes = 0) { + $b64 = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($id)).TrimEnd('=') + $dirName = ('01-00-00-000-session_' + $b64) + $dir = Join-Path $dayDir $dirName + New-Item -ItemType Directory -Path $dir -Force | Out-Null + $f = Join-Path $dir 'messages.jsonl' + [System.IO.File]::WriteAllText($f, (($lines -join "`n") + "`n"), (New-Object System.Text.UTF8Encoding($false))) + if ($ageMinutes -gt 0) { + [System.IO.File]::SetLastWriteTime($f, (Get-Date).AddMinutes(-1 * $ageMinutes)) + } + return $f +} +function Line-User([string]$text, [string]$extra = '') { + $t = $text -replace '\\', '\\' + $t = $t -replace "`r`n", '\n' + $t = $t -replace "`n", '\n' + $t = $t -replace '"', '\"' + return '{"message":{"role":"user","content":[{"type":"text","text":"' + $t + '"}],"timestamp":1}}' +} +function Line-Assistant([string]$text) { + $t = $text -replace "`r`n", '\n' + $t = $t -replace "`n", '\n' + $t = $t -replace '"', '\"' + return '{"message":{"role":"assistant","content":[{"type":"text","text":"' + $t + '"}],"timestamp":2}}' +} +function Line-ToolCall([string]$tool) { + return '{"message":{"role":"assistant","content":[{"type":"toolCall","name":"' + $tool + '","arguments":{}}],"timestamp":3}}' +} +# ledger 事件格式:{kind, phase, action, ...} +# +# 真实环境里大多数 session 没有 ledger.jsonl(417 个里只有 1 个),但 +# detector 的 Infer-State 有这个分支,列表也得跟着有 —— 不然同一个会话 +# 在两边的状态会不一样。 +function Line-LedgerToolResult { + return '{"kind":"toolResult","toolName":"bash","ts":1234}' +} +function Line-LedgerToolCall { + return '{"kind":"toolCall","toolName":"bash","action":"doing it","ts":1234}' +} +# A tool that failed. The detector reports these as `error`, so the list +# has to be able to say it too. +function Line-ToolResultFailed { + return '{"message":{"role":"toolResult","isError":true,"content":[{"type":"text","text":"boom"}],"timestamp":4}}' +} +# An assistant message with only a thinking block: it is thinking, not +# done and not working. +function Line-ThinkingOnly { + return '{"message":{"role":"assistant","content":[{"type":"thinking","thinking":"hmm"}],"timestamp":4}}' +} +# 系统注入的消息:后台任务完成通知、系统提醒这些,是以 user 角色塞进 +# 会话日志的,但它们不是"有人在说话"。 +function Line-SystemNoise { + return '{"message":{"role":"user","content":[{"type":"text","text":" noise from the system"}],"timestamp":4}}' +} +function Line-ToolResult { + return '{"message":{"role":"toolResult","content":[{"type":"text","text":"ok"}],"timestamp":4}}' +} +# A toolResult that names its tool. The real log puts the tool name on +# message.toolName, not in the content array -- and a toolResult is what +# the last line usually is once a tool has run. +function Line-ToolResultNamed([string]$tool) { + return '{"message":{"role":"toolResult","toolName":"' + $tool + '","content":[{"type":"text","text":"ok"}],"timestamp":4}}' +} + +# --- 1. 状态机 ----------------------------------------------------------- +$fTool = New-Session 'sess-running' @((Line-ToolCall 'Bash')) +$fDone = New-Session 'sess-waiting' @((Line-Assistant '我先去做点别的')) +$fAsk = New-Session 'sess-asking' @((Line-ToolCall 'ask_user')) +$fUser = New-Session 'sess-userjust' @((Line-User '帮我看下这个')) + +# 这些状态词表和 pill(detector 的 Infer-State)是同一套:同一个会话在 +# 两个地方不能一个说"等用户"、一个说"运行中"。 +# +# toolCall 结尾 -> working (在跑) +# assistant 纯文本结尾 -> idle (说完了,等你) +# ask_user 结尾 -> waiting (在等审批) +# user 刚说完 -> idle (球在你那边,agent 马上要动) +# toolResult 成功 -> done (工具跑完了) +# toolResult 失败 -> error +# assistant 只有 thinking -> thinking(在想) +Check 'toolCall 结尾 = 运行中' ((Get-SessionState (Get-LastJsonLine $fTool)) -eq 'working') (Get-SessionState (Get-LastJsonLine $fTool)) +Check 'assistant 文本结尾 = 待回复' ((Get-SessionState (Get-LastJsonLine $fDone)) -eq 'idle') (Get-SessionState (Get-LastJsonLine $fDone)) +Check 'ask_user 结尾 = 等待审批' ((Get-SessionState (Get-LastJsonLine $fAsk)) -eq 'waiting') (Get-SessionState (Get-LastJsonLine $fAsk)) +Check 'user 刚说完 = 待回复' ((Get-SessionState (Get-LastJsonLine $fUser)) -eq 'idle') (Get-SessionState (Get-LastJsonLine $fUser)) + +# 工具跑完 / 炸了 / 只想不动手。 +# +# 工具跑完(成不成都一样)仍然是"运行中":agent 还在这一轮里,只是刚跑完 +# 一个工具。pill 会闪一下"已完成 + Read",那是瞬时反馈;列表说的是会话 +# 级的状态,说"已完成"会被读成"这个会话做完了"。 +$fRes = New-Session 'sess-toolresult' @((Line-ToolResult)) +$fResErr = New-Session 'sess-toolerror' @((Line-ToolResultFailed)) +$fThink = New-Session 'sess-thinking' @((Line-ThinkingOnly)) +Check 'toolResult 结尾 = 运行中(这一轮还没完)' ((Get-SessionState (Get-LastJsonLine $fRes)) -eq 'working') (Get-SessionState (Get-LastJsonLine $fRes)) +Check '工具失败也是运行中(agent 会接着处理)' ((Get-SessionState (Get-LastJsonLine $fResErr)) -eq 'working') (Get-SessionState (Get-LastJsonLine $fResErr)) + +# ledger 事件格式走的是另一个分支,规则一样:工具跑完了,这一轮还没完。 +$fLedgerRes = New-Session 'sess-ledger-result' @((Line-LedgerToolResult)) +$fLedgerCall = New-Session 'sess-ledger-call' @((Line-LedgerToolCall)) +Check 'ledger 的 toolResult 也是运行中' ((Get-SessionState (Get-LastJsonLine $fLedgerRes)) -eq 'working') (Get-SessionState (Get-LastJsonLine $fLedgerRes)) +Check 'ledger 的 toolCall 是运行中' ((Get-SessionState (Get-LastJsonLine $fLedgerCall)) -eq 'working') (Get-SessionState (Get-LastJsonLine $fLedgerCall)) +Check 'assistant 只想不动手 = 思考中' ((Get-SessionState (Get-LastJsonLine $fThink)) -eq 'thinking') (Get-SessionState (Get-LastJsonLine $fThink)) + +# --- 2. 标题提取 --------------------------------------------------------- +$reminder = "`nxxx`n`n帮我把 PR 看一下" +$fTitle = New-Session 'sess-title' @((Line-User $reminder)) +Check '标题剥掉 system-reminder 后是用户提问' ((Get-SessionTitle $fTitle) -eq '帮我把 PR 看一下') ("得到: " + (Get-SessionTitle $fTitle)) + +$fCompact = New-Session 'sess-compact' @('{"message":{"role":"compactionSummary","content":[]}}', (Line-Assistant '我来实现选项 ②')) +Check '被压缩的会话退回 assistant 首句' ((Get-SessionTitle $fCompact) -like '我来实现选项*') ("得到: " + (Get-SessionTitle $fCompact)) + +# --- 3. 入列规则:旧的 session 不进列表 ----------------------------------- +$oldDir = New-Session 'sess-old' @((Line-ToolCall 'Bash')) 60 # 60 分钟前 +[System.IO.File]::SetLastWriteTime($oldDir, (Get-Date).AddMinutes(-60)) +$script:trackedSessions = [ordered]@{} +$script:dismissedSessions = @{} +$script:sessionTitleCache = @{} +Update-SessionTracker +Check '很久没动的 session 不进列表' (-not $script:trackedSessions.Contains('sess-old')) ("列表里有: " + (($script:trackedSessions.Keys) -join ',')) + +# 新的 session 要进列表 +$fNew = New-Session 'sess-new' @((Line-ToolCall 'Bash')) +Update-SessionTracker +Check '刚动过的 session 进列表' ($script:trackedSessions.Contains('sess-new')) ("列表: " + (($script:trackedSessions.Keys) -join ',')) + +# --- 4. 状态从运行中变成待回复 ------------------------------------------- +$fNew2 = New-Session 'sess-flip' @((Line-ToolCall 'Bash')) +Update-SessionTracker +$before = $script:trackedSessions['sess-flip'].Phase +[System.IO.File]::WriteAllText($fNew2, ((Line-Assistant '做完了') + "`n"), (New-Object System.Text.UTF8Encoding($false))) +Update-SessionTracker +$after = $script:trackedSessions['sess-flip'].Phase +Check '运行中 -> 待回复 的状态转换' (($before -eq 'working') -and ($after -eq 'idle')) ("$before -> $after") + +# --- 5. 消除:ack.signal ---------------------------------------------------- +[System.IO.File]::WriteAllText((Join-Path $configDir 'ack.signal'), 'sess-flip', (New-Object System.Text.UTF8Encoding($false))) +Update-SessionTracker +Check 'ack.signal 里的 session 被消掉' (-not $script:trackedSessions.Contains('sess-flip')) ("列表: " + (($script:trackedSessions.Keys) -join ',')) +Check '消除被记进 dismissed' ($script:dismissedSessions.Contains('sess-flip')) ($script:dismissedSessions | ConvertTo-Json -Compress) +Check 'ack.signal 本身被消费掉' (-not (Test-Path (Join-Path $configDir 'ack.signal'))) '文件还在' + +# 一次消多条。 +# +# 列表每行都有 × 可以单独消除,"一秒内消掉两三条"是正常操作。以前 +# ack.signal 一个文件只装一个 id,后写的盖掉先写的 —— 那一条就永远消不 +# 掉,而且用户不知道它没被消(点 × 的那一下看着是生效了的)。 +# +# 所以改成一行一个 id。 +$fMulti1 = New-Session 'sess-multi-1' @((Line-User '要消掉的第一个')) +$fMulti2 = New-Session 'sess-multi-2' @((Line-User '要消掉的第二个')) +$fMulti3 = New-Session 'sess-multi-3' @((Line-User '要消掉的第三个')) +Update-SessionTracker +Check '三条待消除的会话都在列表里' ($script:trackedSessions.Contains('sess-multi-1') -and $script:trackedSessions.Contains('sess-multi-2') -and $script:trackedSessions.Contains('sess-multi-3')) ("列表: " + (($script:trackedSessions.Keys) -join ',')) +[System.IO.File]::WriteAllText((Join-Path $configDir 'ack.signal'), "sess-multi-1`nsess-multi-2`nsess-multi-3`n", (New-Object System.Text.UTF8Encoding($false))) +Update-SessionTracker +Check '一次 ack 里的三条都被消掉' ((-not $script:trackedSessions.Contains('sess-multi-1')) -and (-not $script:trackedSessions.Contains('sess-multi-2')) -and (-not $script:trackedSessions.Contains('sess-multi-3'))) ("列表: " + (($script:trackedSessions.Keys) -join ',')) +Check '三条都记进了 dismissed' ($script:dismissedSessions.Contains('sess-multi-1') -and $script:dismissedSessions.Contains('sess-multi-2') -and $script:dismissedSessions.Contains('sess-multi-3')) ($script:dismissedSessions | ConvertTo-Json -Compress) +Check '消费后没有留下 .taken 残file' (-not (Test-Path (Join-Path $configDir 'ack.signal.taken'))) '有残file' + +# 只写一行(末尾没有换行)也要能消掉 —— widget 写的每一行都带换行,但 +# 手工写一个 id 也要认。 +$fSolo = New-Session 'sess-solo-ack' @((Line-User '单独消一条')) +Update-SessionTracker +[System.IO.File]::WriteAllText((Join-Path $configDir 'ack.signal'), 'sess-solo-ack', (New-Object System.Text.UTF8Encoding($false))) +Update-SessionTracker +Check '单个 id(无换行结尾)也被消掉' (-not $script:trackedSessions.Contains('sess-solo-ack')) ("列表: " + (($script:trackedSessions.Keys) -join ',')) + +# --- 6. 持久化 ------------------------------------------------------------- +Write-SessionsFile -Force +$saved = Get-Content (Join-Path $configDir 'sessions.json') -Raw | ConvertFrom-Json +Check 'sessions.json 写出了列表' (@($saved.sessions).Count -gt 0) ("数量: " + @($saved.sessions).Count) +Check 'sessions.json 记下了 dismissed' ($null -ne $saved.dismissed.'sess-flip') ($saved.dismissed | ConvertTo-Json -Compress) + +# 重启模拟:清空内存,从文件恢复 +$script:trackedSessions = [ordered]@{} +$script:dismissedSessions = @{} +Restore-SessionTracker +Check '重启后列表从 sessions.json 恢复' ($script:trackedSessions.Contains('sess-new')) ("恢复后: " + (($script:trackedSessions.Keys) -join ',')) +Check '重启后 dismissed 也恢复' ($script:dismissedSessions.Contains('sess-flip')) ($script:dismissedSessions | ConvertTo-Json -Compress) + +# --- 7. cycle:被消掉的 session 跑完一轮又回来 ------------------------------- +$fCycle = New-Session 'sess-cycle' @((Line-ToolCall 'Bash')) +Update-SessionTracker +Check '新 cycle 的 session 进列表' ($script:trackedSessions.Contains('sess-cycle')) '没进来' +[System.IO.File]::WriteAllText((Join-Path $configDir 'ack.signal'), 'sess-cycle', (New-Object System.Text.UTF8Encoding($false))) +Update-SessionTracker +Check '被消掉后不在列表里' (-not $script:trackedSessions.Contains('sess-cycle')) '还在' +# agent 说完了 -> waiting +[System.IO.File]::WriteAllText($fCycle, ((Line-Assistant '好了') + "`n"), (New-Object System.Text.UTF8Encoding($false))) +Update-SessionTracker +# agent 又开始 -> running,cycle +1,dismissed 失效 +[System.IO.File]::WriteAllText($fCycle, ((Line-ToolCall 'Bash') + "`n"), (New-Object System.Text.UTF8Encoding($false))) +Update-SessionTracker +Check '新一轮开始后 session 重新进列表' ($script:trackedSessions.Contains('sess-cycle')) '没回来' +Check '新一轮清掉了 dismissed 记录' (-not $script:dismissedSessions.Contains('sess-cycle')) ($script:dismissedSessions | ConvertTo-Json -Compress) + +# --- 8. 摘要与时间戳:列表的第二行和右侧那一列 --------------------------- +# +# 布局参考小红书的展开态之后,列表的一行有两行字和一个时间戳: +# 第一行是首条提问,第二行是"最后说了什么",右边是最后活动时间。 +# +# 这三条断言盯的就是那条数据链: +# preview = 最后一条消息的正文(agent 或用户说的) +# lastTool = 最后一条消息恰好是 toolCall 时的工具名 +# lastAtMs = 最后一次活动时间(日志 mtime) +# +# 少任何一条,第二行就会空着,或者时间戳变成"进列表的时间"(那是很久 +# 以前,排序和显示都不对)。 + +$fPrev = New-Session 'sess-preview' @((Line-Assistant '我改完了,你看一下')) +Update-SessionTracker +$ePrev = $script:trackedSessions['sess-preview'] +Check 'assistant 的正文进 preview' ($ePrev.Preview -eq '我改完了,你看一下') ("得到: '" + $ePrev.Preview + "'") +Check '最后活动时间有值' ($ePrev.LastAtMs -gt 0) ("得到: " + $ePrev.LastAtMs) +Check '最后活动时间接近现在' ([math]::Abs(([DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() - [int64]$ePrev.LastAtMs)) -lt 60000) ("得到: " + $ePrev.LastAtMs) + +# toolCall 结尾:没有正文,preview 空,但工具名要留着给 widget 兜底。 +$fToolPrev = New-Session 'sess-toolpreview' @((Line-ToolCall 'Bash')) +Update-SessionTracker +$eToolPrev = $script:trackedSessions['sess-toolpreview'] +Check 'toolCall 结尾时 preview 为空' ([string]::IsNullOrEmpty($eToolPrev.Preview)) ("得到: '" + $eToolPrev.Preview + "'") +Check 'toolCall 结尾时工具名被记下来' ($eToolPrev.LastTool -eq 'Bash') ("得到: '" + $eToolPrev.LastTool + "'") + +# toolResult 结尾:正文是工具输出(代码、日志),不当摘要。 +$fResPrev = New-Session 'sess-resultpreview' @((Line-ToolResult)) +Update-SessionTracker +$eResPrev = $script:trackedSessions['sess-resultpreview'] +Check 'toolResult 的正文不进 preview' ([string]::IsNullOrEmpty($eResPrev.Preview)) ("得到: '" + $eResPrev.Preview + "'") + +# toolResult 带 toolName:工具名在 message.toolName 上,不在 content 里。 +# agent 跑完工具后最后一条往往就是 toolResult,这时 preview 是空的, +# 工具名是唯一能让第二行不空的东西。 +$fResNamed = New-Session 'sess-resultnamed' @((Line-ToolResultNamed 'Bash')) +Update-SessionTracker +$eResNamed = $script:trackedSessions['sess-resultnamed'] +Check 'toolResult 的工具名被记下来' ($eResNamed.LastTool -eq 'Bash') ("得到: '" + $eResNamed.LastTool + "'") +Check 'toolResult 的正文仍然不进 preview' ([string]::IsNullOrEmpty($eResNamed.Preview)) ("得到: '" + $eResNamed.Preview + "'") + +# 换了最后一条消息,preview 必须跟着换(不是只在新 session 时取一次)。 +[System.IO.File]::WriteAllText($fPrev, ((Line-Assistant '改完了' ) + "`n"), (New-Object System.Text.UTF8Encoding($false))) +Update-SessionTracker +Check '最后一条消息变了 preview 跟着变' ($script:trackedSessions['sess-preview'].Preview -eq '改完了') ("得到: '" + $script:trackedSessions['sess-preview'].Preview + "'") + +# 落盘:三个字段都得在文件里,widget 只读这个文件。 +Write-SessionsFile -Force +$saved2 = Get-Content (Join-Path $configDir 'sessions.json') -Raw | ConvertFrom-Json +$row2 = @($saved2.sessions | Where-Object { $_.id -eq 'sess-preview' })[0] +Check 'sessions.json 里有一行' ($null -ne $row2) '没有 sess-preview 这一行' +Check 'sessions.json 带 preview' ($null -ne $row2 -and $row2.preview -eq '改完了') ("得到: '" + $row2.preview + "'") +Check 'sessions.json 带 lastTool' ($null -ne $row2 -and $null -ne $row2.lastTool) ("得到: '" + $row2.lastTool + "'") +Check 'sessions.json 带 lastAtMs' ($null -ne $row2 -and [int64]$row2.lastAtMs -gt 0) ("得到: " + $row2.lastAtMs) + +# 重启恢复:detector 重启到下一次刷新之间,widget 读的就是这个文件, +# 第二行不能白屏。 +$script:trackedSessions = [ordered]@{} +Restore-SessionTracker +$eRestored = $script:trackedSessions['sess-preview'] +Check '重启后 preview 恢复了' ($eRestored.Preview -eq '改完了') ("得到: '" + $eRestored.Preview + "'") +Check '重启后 lastAtMs 恢复了' ([int64]$eRestored.LastAtMs -gt 0) ("得到: " + $eRestored.LastAtMs) + +# 系统注入的消息不能当摘要。 +# +# 系统把后台任务完成通知这些以 user 角色塞进日志。它们不是人话,拿来当 +# 摘要,列表第二行就会显示 " Local backend..."。 +$fNoise = New-Session 'sess-noise' @((Line-SystemNoise)) +$noiseMsg = Get-LastJsonLine $fNoise +Check '系统注入的消息不进 preview' ([string]::IsNullOrEmpty((Get-SessionPreview $noiseMsg))) ("得到: '" + (Get-SessionPreview $noiseMsg) + "'") +Check '标题也不取系统注入的那条' (-not ((Get-SessionTitle $fNoise) -match 'background-task')) ("得到: '" + (Get-SessionTitle $fNoise) + "'") + +# 审核要求:上架版不能借用 MiniMax Code 自己的程序(设一个环境变量让它以 +# node 模式启动)当 node 用,也不能读它自己的 SQLite。所以那条链路整个不存在 +# 了 —— 标题只有首条用户提问这一个来源。 +$trackerSrc = [System.IO.File]::ReadAllText((Join-Path (Join-Path $PSScriptRoot 'lib') 'Session-Tracker.ps1')) +Check 'tracker 不使用那个 node 环境变量' (-not ($trackerSrc -match '\$env:ELECTRON_RUN_AS_NODE')) '源码里又用了它' +Check 'tracker 不读运行时的 sqlite' (-not ($trackerSrc -match '(?i)runtime-state\.sqlite|runtime_titles|Runtime-Titles')) '源码里又出现了运行时的 sqlite' +Check 'Runtime-Titles.ps1 已经不在仓库里' (-not (Test-Path (Join-Path (Join-Path $PSScriptRoot 'lib') 'Runtime-Titles.ps1'))) 'Runtime-Titles.ps1 还在' + +# 标题历史。 +# +# 终端 tab 上的名字是 mcode 在**某个时刻**设的,而会话标题会随着对话 +# 推进变。widget 靠这些名字认出"哪个 tab 是这个会话",所以历史里必须有 +# 它:至少要有首条用户消息那一个。 +$fHist = New-Session 'sess-hist' @((Line-User '首条提问是另外一句话')) +Update-SessionTracker +Write-SessionsFile -Force +$histJson = Get-Content (Join-Path $configDir 'sessions.json') -Raw | ConvertFrom-Json +$histRow = @($histJson.sessions | Where-Object { $_.id -eq 'sess-hist' })[0] +Check 'sessions.json 记下了标题历史' (@($histRow.titles).Count -ge 1) ("得到: " + (@($histRow.titles) -join ' | ')) +Check '标题历史里有首条用户消息' (@($histRow.titles) -contains '首条提问是另外一句话') ("得到: " + (@($histRow.titles) -join ' | ')) + +# Phase 没变但内容变了,也要落盘。 +# +# agent 连着跑两个工具时 Phase 一直是 running,摘要和最后活动时间却在变。 +# 只看 Phase 变没变来决定写不写盘,磁盘上的 sessions.json 就会一直停在 +# 旧值:列表第二行空着、右侧的时间戳不动。内存里是对的,落到文件上丢了。 +$fSame = New-Session 'sess-samephase' @((Line-ToolCall 'Bash')) +Update-SessionTracker +Write-SessionsFile -Force +$beforeSame = Get-Content (Join-Path $configDir 'sessions.json') -Raw | ConvertFrom-Json +$rowBefore = @($beforeSame.sessions | Where-Object { $_.id -eq 'sess-samephase' })[0] +Check '同 Phase 的 session 带上了工具名' ($rowBefore.lastTool -eq 'Bash') ("得到: '" + $rowBefore.lastTool + "'") + +# 换一个工具名。Phase 仍然是 running(toolCall -> toolCall)。 +[System.IO.File]::WriteAllText($fSame, ((Line-ToolCall 'Read') + "`n"), (New-Object System.Text.UTF8Encoding($false))) +Update-SessionTracker +Write-SessionsFile # 故意不加 -Force: 该由 dirty 标志决定写不写 +$afterSame = Get-Content (Join-Path $configDir 'sessions.json') -Raw | ConvertFrom-Json +$rowAfter = @($afterSame.sessions | Where-Object { $_.id -eq 'sess-samephase' })[0] +Check 'Phase 没变但工具名变了也要落盘' ($rowAfter.lastTool -eq 'Read') ("得到: '" + $rowAfter.lastTool + "'") + +# --- 9. 会话标题:只有首条用户提问一个来源 --------------------------------- +# +# 审核要求:上架版不能借用 MiniMax Code 自己的程序(ELECTRON_RUN_AS_NODE=1) +# 当 node 用,也不能读它自己的 SQLite。所以标题只有首条提问这一个来源。 +$fTitled = New-Session 'sess-titled' @((Line-User '首条提问是另外一句话')) +Check '显示用首条提问' ((Get-SessionDisplayTitle $fTitled) -eq '首条提问是另外一句话') ("得到: '" + (Get-SessionDisplayTitle $fTitled) + "'") + +# --- 10. 标题和预览:先截断、先脱敏,再写盘和显示 -------------------------- +# +# 审核第 4 条。标题和预览都是"会话里说过的话",里面可能有 token。写进 +# sessions.json 的是它们,画在屏幕上的也是它们。 +$fSecret = New-Session 'sess-secret' @((Line-User '我的 key 是 sk-abcdef123456 谢谢')) +$secretTitle = (Get-SessionTitle $fSecret) +Check '标题里的密钥被脱敏' ($secretTitle -notmatch 'sk-abcdef123456') ("得到: '" + $secretTitle + "'") +Check '标题仍然可读' ($secretTitle -match '我的 key') ("得到: '" + $secretTitle + "'") + +$longLine = 'x' * 400 +$fLong = New-Session 'sess-long' @((Line-User $longLine)) +Check '标题被截断到 40 字以内' ((Get-SessionTitle $fLong).Length -le 41) ("得到 " + (Get-SessionTitle $fLong).Length + " 字") + +$previewMsg = Get-LastJsonLine (New-Session 'sess-prev' @((Line-User ('预览里也有 sk-abcdef123456 和 ' + ('y' * 300))))) +$preview = (Get-SessionPreview $previewMsg) +Check '预览里的密钥被脱敏' ($preview -notmatch 'sk-abcdef123456') ("得到: '" + $preview + "'") +Check '预览被截断' ($preview.Length -le 121) ("得到 " + $preview.Length + " 字") + +# 写盘的那一份也必须已经是脱敏 + 截断过的 +$fDisk = New-Session 'sess-disk' @((Line-User '写盘测试 sk-abcdef123456')) +Update-SessionTracker +Write-SessionsFile -Force +$diskJson = Get-Content (Join-Path $configDir 'sessions.json') -Raw | ConvertFrom-Json +$diskRow = @($diskJson.sessions | Where-Object { $_.id -eq 'sess-disk' })[0] +if ($diskRow) { + Check 'sessions.json 里的标题是脱敏过的' ([string]$diskRow.title -notmatch 'sk-abcdef123456') ("得到: '" + $diskRow.title + "'") + Check 'sessions.json 里的预览也是脱敏过的' ([string]$diskRow.preview -notmatch 'sk-abcdef123456') ("得到: '" + $diskRow.preview + "'") +} else { + Check 'sessions.json 里有这一条' $false '磁盘上找不到 sess-disk' +} + +# --- 11. 过期会话的日志一次都不该被读 -------------------------------------- +# +# 审核第 1 条。之前是先读日志再判过期:本机 400 多个历史会话的日志每秒 +# 被读一遍,读完再丢掉。 +$script:readFiles.Clear() +$fOld = New-Session 'sess-ancient' @((Line-User '很久以前的会话')) 30 +Update-SessionTracker +$oldLog = Join-Path (Split-Path -Parent $fOld) 'messages.jsonl' +Check '过期会话的日志没有被读' (-not ($script:readFiles -contains $oldLog)) ("读了: " + ($script:readFiles -join ', ')) +Check '过期会话也不进列表' (-not $script:trackedSessions.Contains('sess-ancient')) '过期会话进了列表' + +# --- 12. 列表要能清理 --------------------------------------------------- +# +# 审核第 5 条。列表是"现在还活着的会话",不是历史记录:太久没动的会话 +# 要从列表和 sessions.json 里一起消失。 +$fPrune = New-Session 'sess-prune' @((Line-User '待清理的会话')) +Update-SessionTracker +Check '刚动过的会话在列表里' ($script:trackedSessions.Contains('sess-prune')) '刚动过的会话不在列表里' +# 把它的日志改成很久以前:下次更新就该被清掉 +$pruneLog = Join-Path (Split-Path -Parent $fPrune) 'messages.jsonl' +[System.IO.File]::SetLastWriteTime($pruneLog, (Get-Date).AddHours(-2)) +Update-SessionTracker +Check '很久没动的会话被清出列表' (-not $script:trackedSessions.Contains('sess-prune')) '过期会话还在列表里' +Write-SessionsFile -Force +$pruneJson = Get-Content (Join-Path $configDir 'sessions.json') -Raw | ConvertFrom-Json +Check '被清掉的会话也不在 sessions.json 里' (-not (@($pruneJson.sessions | Where-Object { $_.id -eq 'sess-prune' }).Count)) 'sessions.json 里还有它' +# --- 10. 停了的会话不该一直显示"运行中" ----------------------------------- +# +# agent 跑完一个工具之后,它的最后一条消息永远是 toolCall 或 toolResult +# —— 消息类型说它"在跑"。而日志不再变之后,更新循环第一行就跳过了, +# 状态冻结在最后一个值:一个停了快一小时的会话会一直显示"运行中"。 +# +# 所以"在跑"必须有一份活性支撑:超过窗口没动,就不是"在跑",是"停了"。 +$fStale = New-Session 'sess-stale' @((Line-ToolCall 'Bash')) +Update-SessionTracker +Check '刚动过的 toolCall 会话是运行中' ($script:trackedSessions['sess-stale'].Phase -eq 'working') ("得到: " + $script:trackedSessions['sess-stale'].Phase) + +# 停 10 分钟:文件 mtime 变了,先重读一次(消息没变,状态也没变) +[System.IO.File]::SetLastWriteTimeUtc($fStale, [DateTime]::UtcNow.AddMinutes(-10)) +Update-SessionTracker +# 第二轮:mtime 不会再变了,这一轮走的是"没变就跳过"那条路 +Update-SessionTracker +Check '停了 10 分钟的会话不再显示运行中' ($script:trackedSessions['sess-stale'].Phase -ne 'working') ("得到: " + $script:trackedSessions['sess-stale'].Phase) +Check '停了以后标成已停止' ($script:trackedSessions['sess-stale'].Phase -eq 'stopped') ("得到: " + $script:trackedSessions['sess-stale'].Phase) + +# "待回复"不看活性:球在用户那边,用户半小时不回也是待回复。 +$fWaitStale = New-Session 'sess-waitstale' @((Line-Assistant '说完了,等你')) +Update-SessionTracker +[System.IO.File]::SetLastWriteTimeUtc($fWaitStale, [DateTime]::UtcNow.AddMinutes(-10)) +Update-SessionTracker +Update-SessionTracker +Check '停了 10 分钟的待回复会话仍然是待回复' ($script:trackedSessions['sess-waitstale'].Phase -eq 'idle') ("得到: " + $script:trackedSessions['sess-waitstale'].Phase) + +# thinking 也是"在跑":agent 正在想。停住了一样要降级成"已停止"。 +# +# 这条盯的是 ACTIVE_STATES 是不是完整 —— 只写 working 的话,一个卡在 +# 思考里的会话会永远显示"思考中",和当初那个"停了还显示运行中"是同 +# 一类毛病。 +$fThinkStale = New-Session 'sess-thinkstale' @((Line-ThinkingOnly)) +Update-SessionTracker +Check '刚动过的 thinking 会话是思考中' ($script:trackedSessions['sess-thinkstale'].Phase -eq 'thinking') ("得到: " + $script:trackedSessions['sess-thinkstale'].Phase) +[System.IO.File]::SetLastWriteTimeUtc($fThinkStale, [DateTime]::UtcNow.AddMinutes(-10)) +Update-SessionTracker +Update-SessionTracker +Check '停了 10 分钟的思考中会话降级成已停止' ($script:trackedSessions['sess-thinkstale'].Phase -eq 'stopped') ("得到: " + $script:trackedSessions['sess-thinkstale'].Phase) + +# "已停止"和"待回复"一样,是一轮的终点。之后又变成"运行中",说明 +# 新一轮开始了 —— 被点掉的约束失效,cycle +1。 +$fStopCycle = New-Session 'sess-stopcycle' @((Line-ToolCall 'Bash')) +Update-SessionTracker +[System.IO.File]::SetLastWriteTimeUtc($fStopCycle, [DateTime]::UtcNow.AddMinutes(-10)) +Update-SessionTracker +Update-SessionTracker +Check '停了以后是已停止' ($script:trackedSessions['sess-stopcycle'].Phase -eq 'stopped') ("得到: " + $script:trackedSessions['sess-stopcycle'].Phase) +$cycleBefore = $script:trackedSessions['sess-stopcycle'].Cycle +[System.IO.File]::WriteAllText($fStopCycle, ((Line-ToolCall 'Bash') + "`n"), (New-Object System.Text.UTF8Encoding($false))) +Update-SessionTracker +Check '停了以后又跑起来算新一轮' ($script:trackedSessions['sess-stopcycle'].Cycle -eq ($cycleBefore + 1)) ("得到: " + $script:trackedSessions['sess-stopcycle'].Cycle + ", 之前 " + $cycleBefore) + +# --- 13. 会话 id 的形状 -------------------------------------------------- +# +# id 有两个来源,都不是我们生成的:会话目录名里的 base64,以及上一次跑留在 +# sessions.json 里的条目。它曾经被拼进 `powershell -Command` 的字符串里, +# 于是一个形如 `x; Set-Content ...` 的 id 能执行任意 PowerShell。那条路 +# 已经删掉(匹配不上就失败关闭),这里再盯住 id 本身。 +Check '真实形状的 id 通过' (Test-SessionIdSafe 'mvs_70378ec639b2471a8f205b954e98a589') +Check '带连字符和下划线的 id 通过' (Test-SessionIdSafe 'e2e-test-001') +Check '空 id 不通过' (-not (Test-SessionIdSafe '')) +Check '带分号的 id 不通过' (-not (Test-SessionIdSafe 'x; Set-Content C:\pwn.ps1 1')) +Check '带空格的 id 不通过' (-not (Test-SessionIdSafe 'a b')) +Check '带反引号的 id 不通过' (-not (Test-SessionIdSafe 'a`b')) +Check '带路径的 id 不通过' (-not (Test-SessionIdSafe '..\..\evil')) +Check '超长 id 不通过' (-not (Test-SessionIdSafe ('a' * 200))) + +# 目录名里藏着的 base64 解码出非法 id 时,这个目录要当成"没有会话": +# 没有 id 就没法去重、也没法指向它。 +$evilDir = Split-Path -Parent (New-Session 'x; Remove-Item -Recurse C:\' @((Line-User 'evil'))) +Check '目录名里藏着非法 id 时解不出 id' ((Get-SessionIdFromDir $evilDir) -eq '') ("得到: '" + (Get-SessionIdFromDir $evilDir) + "'") +Update-SessionTracker +Check '非法 id 的会话不进列表' (-not $script:trackedSessions.Contains('x; Remove-Item -Recurse C:\')) '列表里出现了这个 id' + +# --- 14. 会话数有上限 ---------------------------------------------------- +# +# 30 分钟的活性窗口管不住"同一时刻活着的会话很多":一上午开几十个,30 分钟 +# 内它们全都还在,列表顶满屏幕、sessions.json 也长成一本历史书。这里断言 +# 上限确实生效、淘汰的是最后活动更早的那些,而且只淘汰**不活跃**的(淘汰的 +# 会话不能被下一轮扫描当新会话捞回来)。 +$script:trackedSessions.Clear() +$script:dismissedSessions.Clear() +# 前面各组造的夹具日志都是"刚动过",过不了不活跃判定;统一退到 2 分钟前, +# 它们就不会作为新会话被扫回来,这一组只看见自己造的 5 个。 +foreach ($f in @([System.IO.Directory]::EnumerateFiles($sessionsRoot, '*.jsonl', 'AllDirectories'))) { + [System.IO.File]::SetLastWriteTimeUtc($f, [DateTime]::UtcNow.AddMinutes(-2)) +} +$capIds = @() +$capFiles = @() +foreach ($i in 1..5) { + $capIds += "sess-cap-$i" + # 先以"刚动过"的身份进来(新会话只收活性窗口内的),再退到过去。 + $capFiles += (New-Session "sess-cap-$i" @((Line-User ("cap " + $i)))) +} +Update-SessionTracker +Check '5 个新会话都进了列表' ($script:trackedSessions.Count -eq 5) ("得到: " + $script:trackedSessions.Count) +foreach ($i in 1..5) { + # 1 最老、5 最新,每个差一分钟。 + [System.IO.File]::SetLastWriteTimeUtc($capFiles[$i - 1], [DateTime]::UtcNow.AddMinutes(-1 * (10 - $i))) +} +Update-SessionTracker +Check '不活跃后仍在列表里(只是时间变老)' ($script:trackedSessions.Count -eq 5) ("得到: " + $script:trackedSessions.Count) +Check '生产默认上限是个真实的界' ($defaultMax -ge 5 -and $defaultMax -le 50) ("得到: " + $defaultMax) +$script:MAX_SESSIONS = 3 +Update-SessionTracker +Check '会话数被压到上限' ($script:trackedSessions.Count -eq 3) ("得到: " + $script:trackedSessions.Count) +Check '留下的是最后活动更晚的三个' ($script:trackedSessions.Contains('sess-cap-3') -and $script:trackedSessions.Contains('sess-cap-4') -and $script:trackedSessions.Contains('sess-cap-5')) ("留下: " + (@($script:trackedSessions.Keys) -join ',')) +Check '淘汰的是最后活动更早的那些' ((-not $script:trackedSessions.Contains('sess-cap-1')) -and (-not $script:trackedSessions.Contains('sess-cap-2'))) '老会话还在列表里' +# 被淘汰的会话日志在活性窗口外,下一轮扫描不该把它捞回来 —— 否则就是每个 +# tick 淘汰一次、重新加入一次,列表会闪。 +Update-SessionTracker +Check '淘汰的会话不会被下一次扫描捞回来' ($script:trackedSessions.Count -eq 3 -and -not $script:trackedSessions.Contains('sess-cap-1')) ("得到: " + $script:trackedSessions.Count) +# 正在跑的会话不淘汰:把上限压到 1,三个刚动过的会话一个都不该少。 +$script:MAX_SESSIONS = 1 +foreach ($id in @($capIds | Where-Object { $script:trackedSessions.Contains($_) })) { + $log = Join-Path ([string]$script:trackedSessions[$id].Dir) 'messages.jsonl' + if ($log -and (Test-Path -LiteralPath $log)) { [System.IO.File]::SetLastWriteTimeUtc($log, [DateTime]::UtcNow) } +} +Update-SessionTracker +Check '正在跑的会话不会被上限砍掉' (@($capIds | Where-Object { $script:trackedSessions.Contains($_) }).Count -eq 3) ("得到: " + (@($capIds | Where-Object { $script:trackedSessions.Contains($_) }) -join ',')) +$script:MAX_SESSIONS = 1000 + +# --- 15. sessions.json 里的 id 也要过同一道门 --------------------------- +# +# 恢复路径读的是上一次跑写下的文件,那个文件可能被手改过。一个不合法的 id +# 进来,至少会让列表里出现一条点不开的条目。 +$script:trackedSessions.Clear() +$evilJson = '{"sessions":[{"id":"ok-restored","title":"fine","phase":"idle"},{"id":"x; Remove-Item -Recurse C:\\","title":"evil","phase":"working"}]}' +[System.IO.File]::WriteAllText((Join-Path $configDir 'sessions.json'), $evilJson, (New-Object System.Text.UTF8Encoding($false))) +Restore-SessionTracker +Check 'sessions.json 里合法的 id 照常恢复' ($script:trackedSessions.Contains('ok-restored')) '没有恢复 ok-restored' +Check 'sessions.json 里非法的 id 被丢掉' (-not $script:trackedSessions.Contains('x; Remove-Item -Recurse C:\')) '非法 id 进了列表' + +# --- 16. 标题和预览的脱敏覆盖面 ------------------------------------------ +# +# review 第 3 条:标题和预览都会写进 sessions.json,也会画在屏幕上,所以它们 +# 必须过脱敏。这里每种形状各起一个会话,让 canary 出现在**标题**(首条 user +# 消息)和**预览**(最后一条 assistant 消息)里 —— 两边是两条不同的代码路径, +# 只测一边会漏另一边。 +# +# 每条都把 canary 放在最前面:标题会截断到 40 字,藏在后面的 canary 被截掉 +# 后"不含原文"就变成恒真了。断言里要求出现脱敏标记,证明它确实被替换过, +# 而不是恰好没显示。 +$shapes = @( + @{ Name = 'jwt'; Secret = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJjYW5hcnkifQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c' }, + @{ Name = 'bearer'; Secret = '8f3a91c2d4e5f60718293a4b5c6d7e8f' }, + @{ Name = 'json'; Secret = 'json-canary-4c1d2e' }, + @{ Name = 'kv'; Secret = 'kv-canary-9a8b7c' } +) +foreach ($s in $shapes) { + $lead = switch ($s.Name) { + 'bearer' { 'Bearer ' + $s.Secret + ' 看这个' } + 'json' { '{"token":"' + $s.Secret + '"} 配置' } + 'kv' { 'password=' + $s.Secret + ' 登录' } + default { $s.Secret + ' 读一下' } + } + $id = 'sess-leak-' + $s.Name + New-Session $id @((Line-User $lead), (Line-Assistant ('记下了 ' + $lead))) | Out-Null + Update-SessionTracker + $e = $script:trackedSessions[$id] + if (-not $e) { + Check ($s.Name + ':会话进了列表') $false '会话不在列表里,脱敏断言没有意义' + continue + } + $title = [string]$e.Title + $preview = [string]$e.Preview + Check ($s.Name + ':标题里没有原文') (-not $title.Contains([string]$s.Secret)) ("得到: '" + $title + "'") + Check ($s.Name + ':预览里没有原文') (-not $preview.Contains([string]$s.Secret)) ("得到: '" + $preview + "'") + Check ($s.Name + ':标题确实被脱敏过(不是恰好没显示)') ($title -match 'redacted') ("得到: '" + $title + "'") + Check ($s.Name + ':预览确实被脱敏过(不是恰好没显示)') ($preview -match 'redacted') ("得到: '" + $preview + "'") +} +Write-SessionsFile -Force +$diskText = [System.IO.File]::ReadAllText((Join-Path $configDir 'sessions.json')) +foreach ($s in $shapes) { + Check ($s.Name + ':sessions.json 里没有原文') (-not $diskText.Contains([string]$s.Secret)) 'sessions.json 里出现了 canary' +} + +# --- 越界:junction 指向 sessions root 之外,不能被读到 --------------------- +# +# round-24 review (hetaoBackend):以前的两个 scanner 都用 +# EnumerateDirectories / EnumerateFiles(..., 'AllDirectories'),而微软的文档 +# 写明 AllDirectories **包含 reparse point**(符号链接、junction、挂载点): +# https://learn.microsoft.com/en-us/dotnet/api/system.io.searchoption +# +# 所以 sessions 目录底下放一个 junction,递归就会顺着它走进外面:detector +# 会读到外面那个 jsonl,会话列表会把别人的会话内容显示在灵动岛上。 +# +# 这里真的造一个 junction,指向 fixture 外面一个目录,里面放一个 session, +# 然后断言它进不了列表。 +$outsideId = 'outsidesecret' +$outsideB64 = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($outsideId)) +$outsideRoot = Join-Path $fixture 'outside-sessions' +$outsideSession = Join-Path $outsideRoot ('2026-10-23-session_' + $outsideB64) +New-Item -ItemType Directory -Path $outsideSession -Force | Out-Null +[System.IO.File]::WriteAllText( + (Join-Path $outsideSession 'messages.jsonl'), + ((Line-User 'OUTSIDE-CANARY') -join "`n"), + (New-Object System.Text.UTF8Encoding($false))) +# junction 得起个匹配 *session_* 的名字:遍历的 pattern 在每一层都生效, +# 只有匹配上的目录才会被当成 session 目录。 +$junctionPath = Join-Path $dayDir '2026-10-23-session_junctionlink' +$junctionMade = $false +try { + New-Item -ItemType Junction -Path $junctionPath -Target $outsideRoot -ErrorAction Stop | Out-Null + $junctionMade = $true +} catch { + Check 'junction 造得出来(否则后面几条断言没有意义)' $false ('造 junction 失败: ' + $_.Exception.Message) +} +if ($junctionMade) { + # 先证明它确实是 reparse point(PS 5.1 的 FileInfo 没有 Target 属性) + $jAttr = [System.IO.File]::GetAttributes($junctionPath) + Check 'junction 确实是 reparse point' (($jAttr -band [System.IO.FileAttributes]::ReparsePoint) -eq [System.IO.FileAttributes]::ReparsePoint) ('得到: ' + $jAttr) + # 夹具自证:旧的盲递归**确实**会被骗到。这条要是红,说明这个夹具根本没骗 + # 过旧代码,后面的断言就是假绿。 + $blindDirs = @([System.IO.Directory]::EnumerateDirectories($sessionsRoot, '*session_*', 'AllDirectories')) | + Where-Object { $_ -like ('*' + $outsideB64 + '*') } + Check '这个夹具真的骗得过旧的盲递归(否则后面的断言是假绿)' ($blindDirs.Count -gt 0) ('旧式递归也看不到外部 session,夹具无效: ' + ($blindDirs -join ';')) + # 安全遍历:不返回 junction 底下的东西 + $safeFiles = @(Get-SafeSessionEntries -Root $sessionsRoot -Pattern '*.jsonl' -Files) + $leakedFiles = @($safeFiles | Where-Object { $_ -like ('*' + $outsideB64 + '*') }) + Check '安全遍历不返回 junction 外面的日志' ($leakedFiles.Count -eq 0) ('泄漏: ' + ($leakedFiles -join ';')) + $safeDirs = @(Get-SafeSessionEntries -Root $sessionsRoot -Pattern '*session_*') + $leakedDirs = @($safeDirs | Where-Object { $_ -like ('*' + $outsideB64 + '*') }) + Check '安全遍历不返回 junction 外面的 session 目录' ($leakedDirs.Count -eq 0) ('泄漏: ' + ($leakedDirs -join ';')) + # 列表:外部会话进不来 + $script:trackedSessions.Clear() + $script:dismissedSessions.Clear() + Update-SessionTracker + Check 'junction 外面的会话不进列表' (-not $script:trackedSessions.Contains($outsideId)) ('外部 id 出现在列表里') +} + + +# --- 17. sessions.json 的字节上限 ---------------------------------------- +# +# review 第 4 条要的是"有界的数据"。条数上限够不着文件大小:每条会话还带着 +# titles 历史,dismissed 那张表也没有条数上限,两样都随使用时长慢慢长。所以 +# 写盘前再按字节砍一遍。 +# +# 这里把上限压到 1KB,让它必然触发(8 条长会话远超 1KB),然后看三件事: +# 文件真被砍到界内、最新的会话还在、最旧的被丢了。上限放开后应该又能写全。 +$defaultJsonBytes = $script:MAX_SESSIONS_JSON_BYTES +Check '生产默认字节上限是个真实的界' ($defaultJsonBytes -ge 4096 -and $defaultJsonBytes -le 1048576) ("得到: " + $defaultJsonBytes) + +$script:trackedSessions.Clear() +$script:dismissedSessions.Clear() +$script:MAX_SESSIONS = 1000 +# 把前面各组留下的夹具日志统一退到 10 分钟前:它们不该参与这一组的淘汰 +# 排序,否则"最新"会变成上一组留下的那几条,而不是这一组造的。 +foreach ($f in @([System.IO.Directory]::EnumerateFiles($sessionsRoot, '*.jsonl', 'AllDirectories'))) { + [System.IO.File]::SetLastWriteTimeUtc($f, [DateTime]::UtcNow.AddMinutes(-10)) +} +$byteIds = @() +$byteFiles = @() +foreach ($i in 1..8) { + $byteIds += "sess-byte-$i" + $long = (('L' * 300) + " $i") + $byteFiles += (New-Session "sess-byte-$i" @((Line-User $long), (Line-User $long))) +} +Update-SessionTracker +foreach ($i in 1..8) { + # 和第 14 组一样:显式拉开最后活动时间,"最新"才是确定的 newest。 + [System.IO.File]::SetLastWriteTimeUtc($byteFiles[$i - 1], [DateTime]::UtcNow.AddMinutes(-1 * (10 - $i))) +} +Update-SessionTracker +$inList = @($byteIds | Where-Object { $script:trackedSessions.Contains($_) }) +Check '8 个长会话都进了列表' ($inList.Count -eq 8) ("得到: " + ($inList -join ',')) + +$script:MAX_SESSIONS_JSON_BYTES = 1024 +Write-SessionsFile -Force +$jsonPath = Join-Path $configDir 'sessions.json' +$jsonBytes = (Get-Item -LiteralPath $jsonPath).Length +Check 'sessions.json 不超过字节上限' ($jsonBytes -le 1024) ("得到: " + $jsonBytes + " 字节") +$parsed = ([System.IO.File]::ReadAllText($jsonPath) | ConvertFrom-Json) +$fileIds = @(@($parsed.sessions) | ForEach-Object { [string]$_.id }) +Check 'sessions.json 还是合法 JSON' ($null -ne $parsed) '解析失败' +Check '最新的会话留在了文件里' ($fileIds -contains 'sess-byte-8') ("留下: " + ($fileIds -join ',')) +Check '最旧的会话被丢出了文件' (-not ($fileIds -contains 'sess-byte-1')) ("留下: " + ($fileIds -join ',')) + +$script:MAX_SESSIONS_JSON_BYTES = $defaultJsonBytes +Write-SessionsFile -Force +$parsed2 = ([System.IO.File]::ReadAllText($jsonPath) | ConvertFrom-Json) +$kept8 = @(@($parsed2.sessions) | ForEach-Object { [string]$_.id }) +$inFile = @($byteIds | Where-Object { $kept8 -contains $_ }) +Check '上限放开后 8 个会话都写进去了' ($inFile.Count -eq 8) ("得到: " + ($inFile -join ',')) +$script:trackedSessions.Clear() +$script:dismissedSessions.Clear() +Write-Output "1..$script:n" +Write-Output "# pass $script:n fail $script:fail" +if ($script:fail -gt 0) { exit 1 } +exit 0 diff --git a/plugins/antianqi/mcode-island/scripts/test-substep-progress.mjs b/plugins/antianqi/mcode-island/scripts/test-substep-progress.mjs new file mode 100644 index 00000000..d54ec60d --- /dev/null +++ b/plugins/antianqi/mcode-island/scripts/test-substep-progress.mjs @@ -0,0 +1,1567 @@ +// 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, spawnSync } 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, allowFail) { + const file = join(TMP, `run-${Math.random().toString(36).slice(2)}.ps1`); + // Force UTF-8 on both ends of the pipe. The console code page here is 936 + // (GBK), so a middle dot in a script literal comes back as the two bytes + // 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 r = spawnSync(PS_BIN, ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', file], { + encoding: 'buffer', + stdio: ['ignore', 'pipe', 'pipe'], + env: extraEnv ? { ...process.env, ...extraEnv } : process.env, + }); + // The child's stderr is kept: a harness that dies before its first + // check otherwise reports "0 passed", which reads like "there was + // nothing to test" rather than "the harness is broken". + const errText = (r.stderr || Buffer.alloc(0)).toString('utf8'); + if (r.status !== 0 && !allowFail) { + throw new Error(`PS harness failed (exit ${r.status}): ${errText || (r.stdout || '').toString('utf8')}`); + } + let buf = r.stdout || Buffer.alloc(0); + if (buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf) { + buf = buf.subarray(3); + } + let text = buf.toString('utf8'); + if (errText) { + // Tagged so the TAP parser ignores it, but it is still in the + // output the caller reads. + text += `\n# stderr: ${errText.replace(/\r?\n/g, ' | ')}`; + } + return text; +} + +/** + * 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'); + } + } +} + +// --------------------------------------------------------------------------- +// 10. The multi-session tracker (v0.5.0) +// --------------------------------------------------------------------------- +// +// The tracker is a separate PowerShell harness: it assembles the scope +// the tracker lib expects (the detector's constants, Get-LastJsonLine, +// Test-IsAskUser, the store lock) against a FIXTURE session tree, and +// asserts the state machine, the entry rule, the title extraction, the +// persistence and the dismissal. It emits TAP-ish lines. +// +// It runs in its own process because the tracker lib keeps state in +// $script: variables, and dot-sourcing it into this process would mix +// the fixture's state with the detector's. +if (PS_BIN) { + const trackerSuite = join(PLUGIN_ROOT, 'scripts', 'test-session-tracker.ps1'); + check('the session tracker suite exists', existsSync(trackerSuite), `${trackerSuite} not found`); + + if (existsSync(trackerSuite)) { + const out = runPs(`& '${trackerSuite.replace(/'/g, "''")}'`, undefined, true); + const lines = out.split(/\r?\n/); + let okCount = 0; + let badCount = 0; + const bad = []; + for (const l of lines) { + if (/^ok \d+ - /.test(l)) { okCount++; } + else if (/^not ok \d+ - /.test(l)) { badCount++; bad.push(l.replace(/^not ok \d+ - /, '')); } + } + check('the session tracker suite passes', badCount === 0 && okCount > 0, + `${okCount} passed, ${badCount} failed: ${bad.join(' | ')}`); + for (const b of bad) { + console.log(` FAIL ${b}`); + } + // The count is pinned so a silently empty harness cannot pass. + check('the session tracker suite ran its full set of checks', okCount >= 20, + `only ${okCount} checks ran`); + } +} + +// --------------------------------------------------------------------------- +// 11. The 5h usage survives a tick (the blank-badge regression) +// --------------------------------------------------------------------------- +// +// 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'); + } +} + +// --------------------------------------------------------------------------- +// 11. A session row switches the desktop app to that conversation +// --------------------------------------------------------------------------- +// +// The session list is only worth having if a click actually takes you +// somewhere. On the desktop app the session lives in the app's sidebar, and +// the sidebar row is a Button whose accessibility name is the conversation +// title -- so the row can be invoked. +// +// The UIA lookup is behind Get-AppConversationButton precisely so this can be +// tested without the app running: the test swaps the lookup and the app +// lookup, and checks the contract (found -> invoke, not found -> fall back, +// no app -> fall back). +if (process.platform !== 'win32') { + // The harness Add-Type's the UIAutomation assemblies and swaps the lookup + // by name; neither exists on Linux. The contract is still covered on + // windows-latest, which is the only platform this code runs on. + console.log('[skip] section 11: the app conversation switch needs Windows (UIAutomation assemblies)'); + skipped++; +} else { + const sourcePath = join(PLUGIN_ROOT, 'mcode-island.ps1'); + const src = readFileSync(sourcePath, 'utf8'); + + let switchFn; + try { + switchFn = extractFn(src, 'Switch-ToAppConversation'); + } catch (e) { + bad('Switch-ToAppConversation is extractable', e.message); + } + + if (switchFn) { + ok('Switch-ToAppConversation is extractable'); + + const script = ` +$ErrorActionPreference = 'Stop' +# The extracted function references the UIA pattern type by name, so the +# assemblies have to be loadable even though the lookup itself is stubbed. +Add-Type -AssemblyName UIAutomationClient +Add-Type -AssemblyName UIAutomationTypes +$script:uiaReady = $true +$global:invoked = $false +$global:pattern = [PSCustomObject]@{} +$global:pattern | Add-Member -MemberType ScriptMethod -Name Invoke -Value { $global:invoked = $true } +# The extracted function logs; the harness has no logger. +function Dbg([string]$m) { } + +# The app is running and its sidebar has a row named 'target-session'. +function Find-DesktopAppWindow { return [PSCustomObject]@{ Hwnd = [IntPtr]1234; Pid = 42; Exe = 'fake-app' } } +function Get-AppConversationButton([IntPtr]$h, [string]$n, [string[]]$known) { + $global:knownSeen = @($known) + if ($n -eq 'target-session') { + $el = [PSCustomObject]@{} + $el | Add-Member -MemberType ScriptMethod -Name GetCurrentPattern -Value { param($p) return $global:pattern } + return $el + } + return $null +} +# the session list the widget reads: two known titles, so the lookup can +# resolve the sidebar's conversation list +function Read-SessionsFile { + return @( + [PSCustomObject]@{ id = 'a'; title = 'target-session'; titles = @('target-session') }, + [PSCustomObject]@{ id = 'b'; title = 'other-session'; titles = @('other-session') } + ) +} +${switchFn} + +$r1 = Switch-ToAppConversation @('target-session') +Write-Output ("HIT=" + [string]$r1 + " INVOKED=" + [string]$global:invoked) +Write-Output ("KNOWN=" + [string]$global:knownSeen.Count) + +$r2 = Switch-ToAppConversation @('another-session') +Write-Output ("MISS=" + [string]$r2) + +function Find-DesktopAppWindow { return $null } +$r3 = Switch-ToAppConversation @('target-session') +Write-Output ("NOAPP=" + [string]$r3) +`; + + let out = ''; + try { + out = runPs(script); + } catch (e) { + bad('the app conversation switch honours the contract', + `the extracted function threw: ${(e.stdout || e.message || '').toString().slice(0, 300)}`); + } + + const hit = (out.match(/HIT=(\S*) INVOKED=(\S*)/) || []); + eq('a matching sidebar row switches the conversation', hit[1], 'True'); + eq('the matching row is actually invoked', hit[2], 'True'); + eq('a session that is not in the sidebar falls through', (out.match(/MISS=(\S*)/) || [])[1], 'False'); + eq('no app running falls through', (out.match(/NOAPP=(\S*)/) || [])[1], 'False'); + // The lookup is scoped to the sidebar's conversation list, which is + // found by the known titles -- so the titles have to be handed over. + const known = (out.match(/KNOWN=(\S*)/) || [])[1]; + check('the lookup is handed the known titles to locate the conversation list', + Number(known) >= 2, + `the lookup only got ${known} known title(s)`); + } +} + +// --------------------------------------------------------------------------- +// 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); +} \ No newline at end of file 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..276b72e5 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,66 @@ 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" + +# --- Step 6: the session tracker's own suite --------------------------------- +# The session list (v0.5.0) brought a 500-line suite of its own, and it was +# in no CI: the feature's core tests only ran when somebody remembered to +# run them by hand. A suite that is not wired into CI is a suite that rots. +Write-Host "" +Write-Host "--- Step 6: session tracker suite ---" + +$trackerSuite = Join-Path $repoRoot 'plugins/antianqi/mcode-island/scripts/test-session-tracker.ps1' +if (-not (Test-Path $trackerSuite)) { + throw "Step 6: $trackerSuite not found (cwd: $((Get-Location).Path))" +} +Write-Host "Suite: $((Resolve-Path $trackerSuite).Path)" + +& $trackerSuite +if ($LASTEXITCODE -ne 0) { + throw "Step 6: test-session-tracker.ps1 failed with exit code $LASTEXITCODE" +} + +Write-Host "OK Step 6: session tracker suite" +Write-Host "" +Write-Host "=== All 6 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 {