diff --git a/.github/workflows/mcode-island-windows.yml b/.github/workflows/mcode-island-windows.yml index 2445f47d..ae885716 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,77 @@ 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. + - 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..9c811b99 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,262 @@ 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.** Two 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. + +The list height is also clamped to the work area, so it can never grow +past the screen. + +## 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 | + +**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 +692,68 @@ 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 the source 32 + 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, …), + checks that + the expected assertion turns red each time, and confirms the suite is + green again after the source is restored. +- 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..7984df50 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 +316,29 @@ $xaml = @' - - + @@ -197,7 +347,7 @@ $xaml = @' + Fill="#FF0A84FF"> @@ -214,10 +364,46 @@ $xaml = @' + + + + + + + + + + + + + + + + + '@ @@ -241,6 +427,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 +435,2007 @@ $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 } + 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 = '从列表中消除(不跳转)' + + # 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()) + + $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 +2549,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 +2584,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 +2601,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 +2647,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 +2688,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 +2718,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 "RESOLVE: re-resolved to hwnd $hwnd" + } + + return [PSCustomObject]@{ Hwnd = $hwnd; Pid = $targetPid; Exe = $targetExe; Source = $targetSource } +} + +# --------------------------------------------------------------------------- +# Click target resolution. +# +# In the desktop app (MiniMax Inside Code / MiniMax Code / Mavis) the agent +# is hosted by the app and runs in a terminal the app opened. That terminal +# belongs to the app session, so "hide the terminal" is meaningless there: +# the agent keeps running behind a static pill. "Take me back to the app +# window" is the action that matches the intent. +# +# The click target is therefore resolved in this order: +# 1. a manual pin (pin.json) -- the user named the target explicitly, so it +# wins. A CLI user pins their terminal and it stays the target even +# while the desktop app is open. +# 2. the desktop app main window -- found through a process-name allowlist +# and focused, never hidden. +# 3. the caller's terminal (caller.json) -- without the desktop app the +# original show/hide toggle is kept. +# --------------------------------------------------------------------------- + +# Process-name allowlist of the desktop app. The app is not an ancestor of +# the agent process (the agent runs in a terminal the app opened), so the +# process tree cannot be used to locate it. +$script:desktopAppNames = @('MiniMax Inside Code', 'MiniMax Code', 'Mavis') + +# Read pin.json: the click target the user pinned by hand. +# pin.json is separate from caller.json -- caller.json is rewritten by the +# agent side on every state transition, so a pin stored there would not +# survive the next notify and priority 1 would be unreachable. +function Resolve-PinnedWindow { + $pinFile = Join-Path $env:APPDATA 'mcode-island\pin.json' + if (!(Test-Path $pinFile)) { return $null } + + $hwnd = [IntPtr]::Zero + $targetPid = 0 + $targetExe = '' + try { + $data = Get-Content $pinFile -Raw -Encoding UTF8 | ConvertFrom-Json + $hwnd = [IntPtr]::new([int64]$data.targetHwnd) + $targetPid = [int]$data.targetPid + $targetExe = if ($data.targetExe) { [string]$data.targetExe } else { '' } + } catch { + Dbg 'PIN: pin.json parse error' + return $null + } + if ($targetPid -le 0) { return $null } + + # the hwnd died (window destroyed or recreated) -> find it again by pid + if ($hwnd -eq [IntPtr]::Zero -or -not [WinAPI]::IsWindow($hwnd)) { + $hwnd = [WinAPI]::FindVisibleWindowForPid([uint32]$targetPid) + } + if ($hwnd -eq [IntPtr]::Zero) { + Dbg "PIN: pinned PID $targetPid is gone" + return $null + } + return [PSCustomObject]@{ Hwnd = $hwnd; Pid = $targetPid; Exe = $targetExe } +} + +# Find the desktop app main window: a process in the same session whose name +# hits the allowlist and that owns a main window. The app is an Electron +# multi-process (main + renderer + GPU + utility); only the main process +# reports a non-zero MainWindowHandle. +function Find-DesktopAppWindow { + $mySession = (Get-Process -Id $PID).SessionId + $cands = @(Get-Process -ErrorAction SilentlyContinue | Where-Object { + $script:desktopAppNames -contains $_.ProcessName -and $_.SessionId -eq $mySession + }) + if ($cands.Count -eq 0) { Dbg 'APP: no desktop app process'; return $null } + + foreach ($p in $cands) { + if ($p.MainWindowHandle -ne [IntPtr]::Zero) { + Dbg "APP: desktop app $($p.ProcessName) PID=$($p.Id) hwnd=$($p.MainWindowHandle)" + return [PSCustomObject]@{ Hwnd = $p.MainWindowHandle; Pid = $p.Id; Exe = $p.ProcessName } } - Dbg "FOCUS: re-resolved to hwnd $hwnd" } + # The main process reports no MainWindowHandle (some Electron builds) -> + # enumerate the visible windows as a fallback. + foreach ($p in $cands) { + $h = [WinAPI]::FindVisibleWindowForPid([uint32]$p.Id) + if ($h -ne [IntPtr]::Zero) { + Dbg "APP: desktop app $($p.ProcessName) PID=$($p.Id) hwnd=$h (enumerated)" + return [PSCustomObject]@{ Hwnd = $h; Pid = $p.Id; Exe = $p.ProcessName } + } + } + Dbg 'APP: desktop app process has no visible window' + return $null +} + +# Which target a click prefers when both are available. +# +# 'terminal' -- the caller's terminal first. The CLI habit: one click +# hides it, the next brings it back. +# 'app' -- the desktop app window first. The Marketplace build ships +# this one: in the desktop app the agent is hosted by the +# app and the terminal belongs to the app session, so hiding +# that terminal leaves a static pill behind. +# +# The Marketplace package is built with 'app'. The builder flips this one +# line and FAILS the build when it cannot find it, so a renamed switch can +# never silently ship the CLI behaviour to the desktop users. +$script:clickPrefers = 'terminal' + +# Resolve the click target. Mode = focus (raise and activate only) / +# toggle (show / hide). +function Resolve-ClickTarget { + # 1) the manual pin -- the user's explicit choice, it always wins + $pin = Resolve-PinnedWindow + if ($pin) { + return [PSCustomObject]@{ Hwnd = $pin.Hwnd; Pid = $pin.Pid; Exe = $pin.Exe; Mode = 'focus'; Origin = 'pin' } + } + # 2) the preferred target, then the other one + if ($script:clickPrefers -eq 'app') { + $app = Find-DesktopAppWindow + if ($app) { + return [PSCustomObject]@{ Hwnd = $app.Hwnd; Pid = $app.Pid; Exe = $app.Exe; Mode = 'focus'; Origin = 'desktop-app' } + } + $caller = Resolve-CallerWindow + if ($caller) { + return [PSCustomObject]@{ Hwnd = $caller.Hwnd; Pid = $caller.Pid; Exe = $caller.Exe; Mode = 'toggle'; Origin = 'caller' } + } + } else { + $caller = Resolve-CallerWindow + if ($caller) { + return [PSCustomObject]@{ Hwnd = $caller.Hwnd; Pid = $caller.Pid; Exe = $caller.Exe; Mode = 'toggle'; Origin = 'caller' } + } + $app = Find-DesktopAppWindow + if ($app) { + return [PSCustomObject]@{ Hwnd = $app.Hwnd; Pid = $app.Pid; Exe = $app.Exe; Mode = 'focus'; Origin = 'desktop-app' } + } + } + return $null +} + +# 把窗口撑到所在显示器的 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 { - # 1) 授权目标进程可以切前台(modern Windows 强制) - [WinAPI]::AllowSetForegroundWindow([uint32]$targetPid) | Out-Null - # 2) 最小化就还原 - if ([WinAPI]::IsIconic($hwnd)) { - [WinAPI]::ShowWindow($hwnd, 9) | Out-Null # SW_RESTORE + [WinAPI]::AllowSetForegroundWindow([uint32]$Target.Pid) | Out-Null + # 只在窗口真的"看不见"时才动它的显示状态: + # 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 } - # 3) 设顶 - [WinAPI]::SetWindowPos($hwnd, [WinAPI]::HWND_TOPMOST, 0, 0, 0, 0, [WinAPI]::SWP_NOACTIVATE) | Out-Null - [WinAPI]::SetWindowPos($hwnd, [IntPtr]::new(-2), 0, 0, 0, 0, [WinAPI]::SWP_NOACTIVATE) | Out-Null # HWND_NOTOPMOST - # 4) 抢焦点 - [WinAPI]::BringWindowToTop($hwnd) | Out-Null - [WinAPI]::SetForegroundWindow($hwnd) | Out-Null - $proc2 = Get-Process -Id $targetPid -ErrorAction SilentlyContinue - Dbg ("FOCUS OK: target=" + $proc2.ProcessName + " PID=" + $targetPid + " hwnd=" + $hwnd) + # TOPMOST -> NOTOPMOST: it lets BringWindowToTop push the window all the + # way up without leaving a "always on top" side effect behind. + # + # 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 +3096,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 +3109,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 +3132,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 +3213,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 +3381,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 +3413,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 +3451,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 +3477,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..d11d054b 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,136 @@ if ($hwnd -ne [IntPtr]::Zero) { [void][DetectHide]::ShowWindow($hwnd, 0) } -function _s { param([byte[]]$b) [System.Text.Encoding]::UTF8.GetString($b) } # role names -$S_USER = _s (0x75,0x73,0x65,0x72) -$S_ASSISTANT = _s (0x61,0x73,0x73,0x69,0x73,0x74,0x61,0x6E,0x74) -$S_TOOLRESULT = _s (0x74,0x6F,0x6F,0x6C,0x52,0x65,0x73,0x75,0x6C,0x74) -$S_COMPACTION = _s (0x63,0x6F,0x6D,0x70,0x61,0x63,0x74,0x69,0x6F,0x6E,0x53,0x75,0x6D,0x6D,0x61,0x72,0x79) +$S_USER = 'user' +$S_ASSISTANT = 'assistant' +$S_TOOLRESULT = 'toolResult' +$S_COMPACTION = 'compactionSummary' # state values -$S_IDLE = _s (0x69,0x64,0x6C,0x65) -$S_THINKING = _s (0x74,0x68,0x69,0x6E,0x6B,0x69,0x6E,0x67) -$S_WORKING = _s (0x77,0x6F,0x72,0x6B,0x69,0x6E,0x67) -$S_DONE = _s (0x64,0x6F,0x6E,0x65) -$S_ERROR = _s (0x65,0x72,0x72,0x6F,0x72) +$S_IDLE = 'idle' +$S_THINKING = 'thinking' +$S_WORKING = 'working' +$S_WAITING = 'waiting' +$S_DONE = 'done' +$S_ERROR = 'error' # content type discriminators -$S_TOOLCALL = _s (0x74,0x6F,0x6F,0x6C,0x43,0x61,0x6C,0x6C) -$S_TEXT = _s (0x74,0x65,0x78,0x74) -$S_THINK_BLK = _s (0x74,0x68,0x69,0x6E,0x6B,0x69,0x6E,0x67) +$S_TOOLCALL = 'toolCall' +$S_TEXT = 'text' +$S_THINK_BLK = 'thinking' # source tag -$S_DETECTOR = _s (0x64,0x65,0x74,0x65,0x63,0x74,0x6F,0x72) +$S_DETECTOR = 'detector' # messages (UTF-8 encoded Chinese) -$MSG_USER_WAIT = _s (0xE7,0xAD,0x89,0xE7,0x94,0xA8,0xE6,0x88,0xB7) # 等用户 -$MSG_AGENT_DONE = _s (0x61,0x67,0x65,0x6E,0x74,0x20,0xE5,0x88,0x9A,0xE5,0x9B,0x9E,0xE5,0xA4,0x8D,0xEF,0xBC,0x8C,0xE7,0xAD,0x89,0xE7,0x94,0xA8,0xE6,0x88,0xB7) # agent 刚回复,等用户 -$MSG_FAIL = _s (0xE5,0xA4,0xB1,0xE8,0xB4,0xA5) # 失败 -$MSG_OK = _s (0xE5,0xAE,0x8C,0xE6,0x88,0x90) # 完成 -$MSG_COMPACT = _s (0x73,0x65,0x73,0x73,0x69,0x6F,0x6E,0x20,0xE5,0x8E,0x8B,0xE7,0xBC,0xA9) # session 压缩 -$MSG_MCODE_EXIT = _s (0x6D,0x63,0x6F,0x64,0x65,0x20,0xE8,0xBF,0x9B,0xE7,0xA8,0x8B,0xE5,0xB7,0xB2,0xE9,0x80,0x80,0xE5,0x87,0xBA) # mcode 进程已退出 -$MSG_IDLE_FMT = _s (0xE5,0xB7,0xB2,0xE9,0x9D,0x99,0xE9,0x9C,0xA8,0x20,0x7B,0x30,0x7D,0x73) # 已静默 {0}s -$DOTS = _s (0x2E,0x2E,0x2E) # ... -$FMT_O = _s (0x6F) # 'o' (ISO 8601 timestamp) -$FMT_O_FULL = _s (0x4F) # 'O' (ISO 8601 full) +$MSG_USER_WAIT = '等用户' # 等用户 +$MSG_AGENT_DONE = 'agent 刚回复,等用户' # agent 刚回复,等用户 +$MSG_COMPACT = 'session 压缩' # session 压缩 +$MSG_IDLE_FMT = '已静默 {0}s' # 已静默 {0}s +$DOTS = '...' # ... +$FMT_O = 'o' # 'o' (ISO 8601 timestamp) +$FMT_O_FULL = 'O' # 'O' (ISO 8601 full) # log reason tags -$R_NO_CURRENT = _s (0x6E,0x6F,0x20,0x63,0x75,0x72,0x72,0x65,0x6E,0x74,0x20,0x73,0x74,0x61,0x74,0x75,0x73) -$R_OWN_CHANGED = _s (0x6F,0x77,0x6E,0x20,0x73,0x74,0x61,0x74,0x65,0x20,0x63,0x68,0x61,0x6E,0x67,0x65,0x64) -$R_OWN_MSG = _s (0x6F,0x77,0x6E,0x20,0x6D,0x65,0x73,0x73,0x61,0x67,0x65,0x20,0x63,0x68,0x61,0x6E,0x67,0x65,0x64) -$R_TAKEOVER = _s (0x74,0x61,0x6B,0x65,0x6F,0x76,0x65,0x72,0x20,0x74,0x6F,0x20,0x73,0x65,0x74,0x74,0x6C,0x65) +$R_NO_CURRENT = 'no current status' +$R_OWN_CHANGED = 'own state changed' +$R_OWN_MSG = 'own message changed' +$R_TAKEOVER = 'takeover to settle' # session log file names (优先 ledger) -$FNAME_LEDGER = _s (0x6C,0x65,0x64,0x67,0x65,0x72,0x2E,0x6A,0x73,0x6F,0x6E,0x6C) # ledger.jsonl -$FNAME_MESSAGES = _s (0x6D,0x65,0x73,0x73,0x61,0x67,0x65,0x73,0x2E,0x6A,0x73,0x6F,0x6E,0x6C) # messages.jsonl -# mcode node cli.js path fragment for cmdline match -$CLI_FRAGMENT = _s (0x40,0x6D,0x69,0x6E,0x69,0x6D,0x61,0x78,0x2D,0x61,0x69,0x2F,0x63,0x6F,0x64,0x65,0x2F,0x63,0x6C,0x69,0x2E,0x6A,0x73) # @minimax-ai/code/cli.js -# .mcode-active directory name -$ACTIVE_DIR = _s (0x2E,0x6D,0x63,0x6F,0x64,0x65,0x2D,0x61,0x63,0x74,0x69,0x76,0x65) # .mcode-active -# .minimax-code directory name (parent of .mcode-active) -$MINIMAX_CODE = _s (0x2E,0x6D,0x69,0x6E,0x69,0x6D,0x61,0x78,0x2D,0x63,0x6F,0x64,0x65) # .minimax-code +$FNAME_LEDGER = 'ledger.jsonl' # ledger.jsonl +$FNAME_MESSAGES = 'messages.jsonl' # messages.jsonl +# tool action verbs (ASCII — matches the mcode CLI TUI descriptor table HL) +# Mirrors @minimax-ai/code launcher-GHPADSKI.js HL[] so the pill shows the same +# present-tense verb ("Running") while the tool is in flight and the same +# past-tense verb ("Ran") once it returns, instead of a generic tool name. +$TOOL_ACTIONS = @{ + 'bash' = @{ running='Running'; done='Ran'; fail='Command failed' } + 'shell' = @{ running='Running'; done='Ran'; fail='Command failed' } + 'edit' = @{ running='Editing'; done='Edited'; fail='Edit failed' } + 'write' = @{ running='Writing'; done='Wrote'; fail='Write failed' } + 'read' = @{ running='Reading'; done='Read'; fail='Read failed' } + 'grep' = @{ running='Searching'; done='Searched'; fail='Search failed' } + 'search' = @{ running='Searching'; done='Searched'; fail='Search failed' } + 'glob' = @{ running='Listing'; done='Listed'; fail='List failed' } + 'list' = @{ running='Listing'; done='Listed'; fail='List failed' } + 'task' = @{ running='Delegating'; done='Delegated'; fail='Delegation failed' } + 'web_search' = @{ running='Web searching'; done='Web searched'; fail='Web search failed' } + 'web_fetch' = @{ running='Fetching'; done='Fetched'; fail='Fetch failed' } + 'todowrite' = @{ running='Planning'; done='Planned'; fail='Plan update failed' } + 'task_output' = @{ running='Reading task'; done='Read task'; fail='Task read failed' } + 'task_append' = @{ running='Steering task'; done='Steered task'; fail='Task steer failed' } + 'task_query' = @{ running='Checking tasks'; done='Checked tasks'; fail='Task check failed' } + 'ask_user' = @{ running='Asking'; done='Asked'; fail='Question failed' } + 'request_feature_enable' = @{ running='Requesting access'; done='Access requested'; fail='Access request failed' } +} +# Tool family — the pill tints its dot by family instead of by state, so a +# read looks different from a write at a glance. Mirrors the `family` field +# of the mcode CLI TUI descriptor table (launcher-GHPADSKI.js HL[]). +# Unknown tools get no family (null) and fall back to the state color. +$TOOL_FAMILIES = @{ + 'bash' = 'shell'; 'shell' = 'shell' + 'read' = 'read' + 'edit' = 'write'; 'write' = 'write' + 'grep' = 'search'; 'search' = 'search'; 'glob' = 'search'; 'list' = 'search' + 'task' = 'task'; 'task_output' = 'task'; 'task_append' = 'task' + # ask_user is deliberately absent: it maps to the `waiting` state (see + # Infer-State), and a waiting pill should wear the state color, not a family + # tint. Giving it 'task' would paint the "come back, I'm blocked" signal in + # the same cyan as an in-flight delegation. + 'web_search' = 'web'; 'web_fetch' = 'web' + 'todowrite' = 'plan' + 'request_feature_enable' = 'plan' +} +# default for tools not in the table (kept lowercase so it reads like a name) +$TOOL_FALLBACK_RUNNING = 'Using' # Using +$TOOL_FALLBACK_DONE = 'Used' # Used # v2 sessions relative to user profile -$V2_SESSIONS_REL = _s (0x2E,0x6D,0x69,0x6E,0x69,0x6D,0x61,0x78,0x5C,0x76,0x32,0x5C,0x73,0x65,0x73,0x73,0x69,0x6F,0x6E,0x73) # .minimax\v2\sessions +$V2_SESSIONS_REL = '.minimax\v2\sessions' # .minimax\v2\sessions $configDir = Join-Path $env:APPDATA 'mcode-island' if (!(Test-Path $configDir)) { New-Item -ItemType Directory -Path $configDir -Force | Out-Null } -$statusFile = Join-Path $configDir 'status.json' +# status.json 的路径由 store 库统一解析(Get-StatusFilePath),这里不再 +# 维护第二份,避免两个来源对不上。 $logFile = Join-Path $configDir 'island.log' $pidFile = Join-Path $configDir 'detect.pid' $cfgFile = Join-Path $configDir 'config.json' # 5h 用量 API:每 60s 调一次 minimax /v1/coding_plan/remains,写进 status.json 的 usage5h 字段 -# token 来源:env MINIMAX_OAUTH_TOKEN 优先;fallback 到 config.json 的 planApiToken # -# The actual API call (Get-5hUsage) and the URL/host constants live -# in scripts/lib/Get-5hUsage.ps1. They are extracted into a lib so -# the windows-latest CI job (and the local runner) can exercise the -# 5h usage path against a mocked listener without requiring a real -# mcode install on the runner. Dot-source the lib here so the -# main loop's Refresh-5hUsage can call Get-5hUsage directly. -. "$PSScriptRoot/scripts/lib/Get-5hUsage.ps1" -$PLAN_API_TTL = [TimeSpan]::FromSeconds(60) -$script:plan5hToken = $null -if ($env:MINIMAX_OAUTH_TOKEN) { $script:plan5hToken = $env:MINIMAX_OAUTH_TOKEN } -elseif ($env:MINIMAX_API_KEY) { $script:plan5hToken = $env:MINIMAX_API_KEY } -elseif (Test-Path $cfgFile) { - try { - $cfg = [System.IO.File]::ReadAllText($cfgFile) | ConvertFrom-Json - if ($cfg.PSObject.Properties['planApiToken'] -and $cfg.planApiToken) { - $script:plan5hToken = [string]$cfg.planApiToken - } - } catch {} +# 这是一个**可选**能力。真正干活的代码(URL、请求头、token 解析、刷新逻辑) +# 全部在 scripts/lib/Get-5hUsage.ps1 里,detector 这里只判断"这个 lib 在不在"。 +# +# lib 不在时(MiniMax Marketplace 的上架包就不携带它),整条 5h 链路直接关闭: +# 不读任何 token、不发任何网络请求、status.json 里也没有 usage5h 字段。 +$script:plan5hAvailable = $false +if (Test-Path "$PSScriptRoot/scripts/lib/Get-5hUsage.ps1") { + . "$PSScriptRoot/scripts/lib/Get-5hUsage.ps1" + $script:plan5hAvailable = $true + Resolve-PlanToken } +# Shared redaction helper (round-20). The detector is the SECOND producer +# of tool text -- it re-derives messages from mcode's session log rather +# than from a hook event, so it needs the same redaction the hook path +# applies. Wiring only the hook left the raw command in island.log. +. "$PSScriptRoot/scripts/lib/Protect-Text.ps1" +# 运行时落盘的套餐用量(零凭据:只读本地文件,自己不联网) +. "$PSScriptRoot/scripts/lib/Plan-Usage.ps1" +# Cross-process status.json protocol (round-21 review #1). +# +# The detector and the agent hooks are two independent processes that +# both write status.json. They had no mutual exclusion: the detector +# read the file, a hook wrote it, and the detector then overwrote the +# hook's write with a payload built from the older read. This lib holds +# the lock, the single snapshot read and the atomic replace, so both +# sides follow one protocol. +. "$PSScriptRoot/scripts/lib/Status-Store.ps1" +# 多 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 +197,117 @@ $script:plan5hTodoData = $null # @{ percent; currentTodo; completed; total $script:plan5hTodoCacheMtime = [DateTime]::MinValue $script:plan5hLastWrittenTodoPct = $null # 上次写到 status.json 的 todoProgress(用来检测变化) -# 解析 mcode 安装根目录(/.minimax-code) +# 解析 MiniMax Code 的数据根目录 +# +# 投放目标是桌面端(MiniMax Code app),所以优先按桌面端的数据目录找: +# 会话流固定在 /v2/sessions,根目录取 的父目录。 +# MINIMAX_DATA_DIR 是官方支持的数据目录覆盖环境变量。 +# +# 之前这里只认 MiniMax Code CLI 的安装目录,而且是从 CLI 的 node 进程 +# cmdline 里反查出来的。桌面端用户没有装 CLI,于是这个函数返回 null, +# detector 直接 exit 2 —— 状态检测在桌面端根本跑不起来。 function Find-McodeRoot { # 1) 用户显式 -Root 覆盖 if ($script:optRoot -and (Test-Path $script:optRoot)) { return $script:optRoot } - # 2) 从 mcode node 进程 cmdline 提取 - $procs = Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | - Where-Object { $_.CommandLine -and $_.CommandLine.Contains($CLI_FRAGMENT) } - foreach ($p in $procs) { - try { - $m = [regex]::Match($p.CommandLine, '([A-Za-z]:\\[^"'']*?)\.minimax-code\\') - if ($m.Success) { - $root = $m.Groups[1].Value + $MINIMAX_CODE - if (Test-Path $root) { return $root } - } - } catch {} - } - - # 3) 探测 $env:USERPROFILE/.minimax-code - if ($env:USERPROFILE) { - $candidate = Join-Path $env:USERPROFILE $MINIMAX_CODE - if (Test-Path $candidate) { return $candidate } - } - - # 4) 探测 $env:USERPROFILE/AppData/Roaming/minimax-code/.minimax-code - if ($env:APPDATA) { - $candidate = Join-Path (Join-Path $env:APPDATA 'minimax-code') $MINIMAX_CODE - if (Test-Path $candidate) { return $candidate } + # 2) 桌面端数据目录 + foreach ($dataDir in Get-DataDirs) { + if (Test-Path (Join-Path $dataDir 'v2\sessions')) { + $parent = Split-Path -Parent $dataDir + if ($parent) { return $parent } + } } - # 5) 探测 /.minimax-code - try { - $cwd = (Get-Location).Path - $candidate = Join-Path $cwd $MINIMAX_CODE - if (Test-Path $candidate) { return $candidate } - } catch {} + # 3) 兜底:用户主目录 + if ($env:USERPROFILE) { return $env:USERPROFILE } return $null } -# sessions 根目录(优先 mcode 安装目录上一级,再 fallback userprofile) +# 桌面端的数据目录候选(按优先级) +function Get-DataDirs { + $dirs = @() + if ($env:MINIMAX_DATA_DIR) { $dirs += $env:MINIMAX_DATA_DIR } + if ($env:USERPROFILE) { $dirs += (Join-Path $env:USERPROFILE '.minimax') } + return $dirs +} + +# sessions 根目录(桌面端优先) function Get-SessionsRoot($mcodeRoot) { - if ($mcodeRoot) { - # .minimax-code 和 .minimax 平级,都在 下 - $parent = Split-Path -Parent $mcodeRoot - $candidate = Join-Path $parent (Join-Path '.minimax' (Join-Path 'v2' 'sessions')) + # 1) 桌面端:/v2/sessions + foreach ($dataDir in Get-DataDirs) { + $candidate = Join-Path $dataDir 'v2\sessions' if (Test-Path $candidate) { return $candidate } } + + # 2) /.minimax/v2/sessions if ($env:USERPROFILE) { $candidate = Join-Path $env:USERPROFILE $V2_SESSIONS_REL if (Test-Path $candidate) { return $candidate } } + + # 3) 兜底:根目录的上一级里找 .minimax/v2/sessions + if ($mcodeRoot) { + $parent = Split-Path -Parent $mcodeRoot + $candidate = Join-Path $parent (Join-Path '.minimax' (Join-Path 'v2' 'sessions')) + if (Test-Path $candidate) { return $candidate } + } + return $null } $script:optRoot = $Root $mcodeRoot = Find-McodeRoot -if (-not $mcodeRoot) { - Write-Error "Cannot find mcode install root (.minimax-code). Pass -Root or ensure mcode is running." - exit 2 -} -$mcodeActiveDir = Join-Path $mcodeRoot $ACTIVE_DIR $sessionsRoot = Get-SessionsRoot $mcodeRoot -if (-not (Test-Path $mcodeActiveDir)) { - Write-Warning ".mcode-active not found at $mcodeActiveDir — mcode not running or different layout" +if (-not $sessionsRoot) { + Write-Error "Cannot find the MiniMax Code session store (~/.minimax/v2/sessions). Set MINIMAX_DATA_DIR or pass -Root." + exit 2 } # 全局状态 $script:lastDetectedState = '' -$script:lastMsgsSnapshot = @{ mtime = ''; lastInferred = $null } +$script:lastMsgsSnapshot = @{ path = ''; mtime = ''; lastInferred = $null } -# 5s 缓存:mcode 进程 PID + 最新 session log 路径 -# 避免每秒调 Get-McodePid / Get-LatestSessionFile(每次都涉及 Get-ChildItem +# 5s 缓存:最新 session log 路径 +# 避免每秒调 Get-LatestSessionFile(每次都涉及 Get-ChildItem # 之类的 cmdlet,PS 5.1 hidden window 下会累积 Runspace 线程,9 小时后 # 池子被占满 → poll 不再前进 → widget 卡死)。 $CACHE_TTL = [TimeSpan]::FromSeconds(5) -$script:lastMcodePid = $null -$script:lastMcodePidAt = [DateTime]::MinValue $script:lastLatestFile = $null $script:lastLatestFileAt = [DateTime]::MinValue +# island.log 上限(round-20) +# +# island.log 一直是裸 Add-Content,从 2026-08-22 起没有任何截断, +# 38 天累积到 18MB。日志内容包含从 session log 推断出来的工具参数, +# 所以"无上限保留"同时是磁盘问题也是留存问题。这里给一个硬上限: +# 超过阈值时只保留最后 $logKeepLines 行。 +# +# 阈值检查不每次调用都做:Add-Content 是 O(1) 追加,先用计数器累计到 +# 1/4 阈值才 stat 一次文件,避免在 400ms 轮询路径上反复摸磁盘。 +$script:logMaxBytes = 1MB +$script:logKeepLines = 300 +$script:logPending = 0 + function Log-Line($msg) { $ts = (Get-Date).ToString('HH:mm:ss') $line = "[$ts] [detect] $msg" Add-Content -Path $logFile -Value $line -Encoding UTF8 - if (-not $Once) { Write-Output $line } -} - -function Get-McodePid { - if (-not (Test-Path $mcodeActiveDir)) { return $null } - # Use [System.IO.Directory]::EnumerateFiles instead of Get-ChildItem + foreach - # to avoid the PS 5.1 pipeline-thread leak that built up over multi-hour - # runs and eventually stalled the detector. See: ~30k leaked threads - # observed after 9h of polling. - $bestPid = $null - $bestMtime = [DateTime]::MinValue - foreach ($f in [System.IO.Directory]::EnumerateFiles($mcodeActiveDir, '*.json')) { + $script:logPending += $line.Length + 2 + if ($script:logPending -ge [int]($script:logMaxBytes / 4)) { + $script:logPending = 0 try { - $raw = [System.IO.File]::ReadAllText($f) - $j = $raw | ConvertFrom-Json - $targetPid = [int]$j.pid - $proc = [System.Diagnostics.Process]::GetProcessById($targetPid) - if ($proc -and -not $proc.HasExited) { - # .mcode-active dir is tiny (one or two .json files); prefer the most - # recently started one if multiple happen to exist. - $mtime = [System.IO.File]::GetLastWriteTime($f) - if ($mtime -gt $bestMtime) { - $bestMtime = $mtime - $bestPid = $targetPid - } + $fi = Get-Item -LiteralPath $logFile -ErrorAction Stop + if ($fi.Length -gt $script:logMaxBytes) { + $keep = @(Get-Content -LiteralPath $logFile -Tail $script:logKeepLines -Encoding UTF8) + [System.IO.File]::WriteAllLines( + $logFile, $keep, (New-Object System.Text.UTF8Encoding($false))) } - } catch {} + } catch { + # 截断失败不能拖垮检测循环;下一轮阈值到了会再试一次。 + } } - return $bestPid + if (-not $Once) { Write-Output $line } } function Get-LatestSessionFile { @@ -288,43 +341,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 +597,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 +648,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 +672,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 +687,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 +864,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 +876,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 +957,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 +1041,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 +1052,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..8534a495 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 上会展开当前所有在跑的会话,每行显示会话的真实名字(取自运行时 SQLite 的会话名)、最后一条消息、相对时间和状态词;点一行会切回那个会话所在的 Windows Terminal 标签并把终端带到前台,而且不会为同一个会话再开一个终端。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/Session-Tracker.ps1 b/plugins/antianqi/mcode-island/scripts/lib/Session-Tracker.ps1 new file mode 100644 index 00000000..e3af9955 --- /dev/null +++ b/plugins/antianqi/mcode-island/scripts/lib/Session-Tracker.ps1 @@ -0,0 +1,831 @@ +# --------------------------------------------------------------------------- +# 多 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) + +# 活动窗口:日志刚被写过,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 + +# 会话 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 } + foreach ($dir in [System.IO.Directory]::EnumerateDirectories($root, '*session_*', 'AllDirectories')) { + $log = $null + $mtime = [DateTime]::MinValue + foreach ($name in @($FNAME_LEDGER, $FNAME_MESSAGES)) { + $p = Join-Path $dir $name + if (!(Test-Path -LiteralPath $p)) { continue } + try { $t = [System.IO.File]::GetLastWriteTimeUtc($p) } catch { continue } + if ($t -gt $mtime) { $mtime = $t; $log = $p } + } + if (-not $log) { continue } + $id = Get-SessionIdFromDir $dir + # 解不出合法 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。 +# +# 只有 detector 写这个文件,所以不存在 agent 侧整份覆盖把它抹掉的问题。 +# 但它和 status.json 在同一个目录、同一台机器上,用同一把锁写,免得两个 +# 进程同时动这个目录。 +function Write-SessionsFile { + param([switch]$Force) + if (-not $Force -and -not $script:sessionsDirty) { return } + $items = @() + foreach ($id in @($script:trackedSessions.Keys)) { + $e = $script:trackedSessions[$id] + $items += [PSCustomObject]@{ + id = $e.Id + title = $e.Title + phase = $e.Phase + sinceMs = $e.SinceMs + cycle = $e.Cycle + # 列表第二行 / 右侧时间戳。缺字段的旧条目补空串,不要 null: + # widget 那边按字符串处理,null 会多一层判空。 + preview = $(if ($e.Preview) { [string]$e.Preview } else { '' }) + lastTool = $(if ($e.LastTool) { [string]$e.LastTool } else { '' }) + lastAtMs = $(if ($e.LastAtMs) { [int64]$e.LastAtMs } else { 0 }) + # 见过的所有标题,widget 拿它去匹配终端 tab。 + titles = @($e.Titles | Where-Object { $_ }) + } + } + $payload = [PSCustomObject]@{ + sessions = $items + dismissed = $script:dismissedSessions + ts = (Get-Date).ToString('o') + } + $lock = Enter-StatusLock + try { + $file = Join-Path $configDir 'sessions.json' + $json = $payload | ConvertTo-Json -Compress -Depth 6 + $tmp = "$file.tmp" + [System.IO.File]::WriteAllText($tmp, $json, [System.Text.Encoding]::UTF8) + Move-Item -Path $tmp -Destination $file -Force + $script:sessionsDirty = $false + } catch { + Log-Line "SESSIONS WRITE FAIL: $($_.Exception.Message)" + } finally { + Exit-StatusLock $lock + } +} + +# detector 启动时把列表读回来(detector 重启不能把用户的"待回复"清零)。 +function Restore-SessionTracker { + $file = Join-Path $configDir 'sessions.json' + if (!(Test-Path -LiteralPath $file)) { return } + try { + $j = [System.IO.File]::ReadAllText($file) | ConvertFrom-Json + foreach ($s in @($j.sessions)) { + if (-not $s.id) { continue } + # 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..353a5bcb 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.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..19d34045 --- /dev/null +++ b/plugins/antianqi/mcode-island/scripts/test-material-injection.mjs @@ -0,0 +1,314 @@ +#!/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 rewrites mcode-island.ps1 while it runs, so keep the working tree clean +// before starting; the file is restored even if the run is interrupted +// between cases (the restore happens in a finally block). + +import { readFile, writeFile } from 'node:fs/promises'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const exec = promisify(execFile); +const ROOT = dirname(dirname(fileURLToPath(import.meta.url))); +const WIDGET = join(ROOT, 'mcode-island.ps1'); +const SMOKE = join(ROOT, 'scripts', 'smoke.mjs'); + +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,'') 会把没配对的 + // " 也是合法的注释结束标记。只认 "-->" 的过滤器会把它 + // 当成没闭合,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', + }, +]; + +async function runSmoke() { + try { + const { stdout } = await exec('node', [SMOKE], { cwd: ROOT, maxBuffer: 64 * 1024 * 1024 }); + return { code: 0, out: stdout }; + } catch (e) { + return { code: e.code ?? 1, out: `${e.stdout || ''}${e.stderr || ''}` }; + } +} + +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++; +} +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..3ececbeb --- /dev/null +++ b/plugins/antianqi/mcode-island/scripts/test-session-tracker.ps1 @@ -0,0 +1,645 @@ +# Behavioral tests for the multi-session tracker. +# +# Emits TAP-ish lines the node suite parses: +# ok N - +# not ok N - : +# +# The tracker lives in a lib that expects the detector's scope +# ($sessionsRoot, $configDir, the state constants, Get-LastJsonLine, +# Test-IsAskUser, the store lock). This harness assembles that scope +# against a FIXTURE session tree, so the assertions run against the real +# functions -- not a re-implementation of them. + +$ErrorActionPreference = 'Stop' + +# The plugin root comes from this script's own location, not from +# $env:USERPROFILE. CI checks the repo out to D:\a\\, so a +# path built from the user profile does not exist there -- the suite +# then dies before its first check and reports "0 passed", which reads +# like "nothing to test" rather than "the harness is broken". +$pluginRoot = Split-Path -Parent $PSScriptRoot +$fixture = Join-Path ([System.IO.Path]::GetTempPath()) ('sess-fixture-' + [guid]::NewGuid().ToString('N').Substring(0, 8)) +$sessionsRoot = Join-Path $fixture 'sessions' +$configDir = Join-Path $fixture 'config' +$dayDir = Join-Path (Join-Path (Join-Path $sessionsRoot '2026') '10') '23' +New-Item -ItemType Directory -Path $dayDir -Force | Out-Null +New-Item -ItemType Directory -Path $configDir -Force | Out-Null + +# The store lib resolves its lock file under %APPDATA%. Point it at the +# fixture: the test must not touch the real store, and the ubuntu CI job +# has no APPDATA at all (Join-Path $null throws there). +$env:APPDATA = Join-Path $fixture 'appdata' +New-Item -ItemType Directory -Path $env:APPDATA -Force | Out-Null + +# --- the scope the lib expects ------------------------------------------- +$S_USER = 'user'; $S_ASSISTANT = 'assistant'; $S_TOOLRESULT = 'toolResult' +$S_TOOLCALL = 'toolCall'; $FNAME_LEDGER = 'ledger.jsonl'; $FNAME_MESSAGES = 'messages.jsonl' +$S_THINKING = 'thinking' +# The tracker reads content blocks by type now, exactly like the +# detector's Infer-State does. Without these two the text/thinking blocks +# are never recognised -- and the state comes out wrong. +$S_TEXT = 'text'; $S_THINK_BLK = 'thinking' +function Log-Line($msg) { } +function Test-IsAskUser($name) { return ([string]$name) -eq 'AskUserQuestion' } + +# The detector's tail reader and ask_user test, verbatim. +$detect = [System.IO.File]::ReadAllText((Join-Path $pluginRoot 'mcode-status-detect.ps1')) +function Extract-Function([string]$src, [string]$name) { + $m = [regex]::Match($src, "(?ms)^function\s+$([regex]::Escape($name))\s*\(.*?^}") + if (-not $m.Success) { throw "function $name not found" } + return $m.Value +} +Invoke-Expression (Extract-Function $detect 'Get-LastJsonLine') +Invoke-Expression (Extract-Function $detect 'Get-ToolKey') +Invoke-Expression (Extract-Function $detect 'Test-IsAskUser') + +# The store lib (the lock) and the tracker lib. +. (Join-Path (Join-Path $pluginRoot 'scripts') (Join-Path 'lib' 'Status-Store.ps1')) +. (Join-Path (Join-Path $pluginRoot 'scripts') (Join-Path 'lib' 'Session-Tracker.ps1')) + +# 脱敏用的是真的那个 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' +} + +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 {