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 b182b75d..a20ad002 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,6 @@ coverage/ *.log __pycache__/ *.pyc + +# 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..52a86ee5 100644 --- a/plugins/antianqi/mcode-island/README.md +++ b/plugins/antianqi/mcode-island/README.md @@ -11,7 +11,9 @@ the terminal in the background and still see exactly what your agent is doing. |:----:|:--------:|:-------:|:-------:|:----:|:-----:| | ![idle](assets/state-idle.png) | ![thinking](assets/state-thinking.png) | ![working](assets/state-working.png) | ![waiting](assets/state-waiting.png) | ![done](assets/state-done.png) | ![error](assets/state-error.png) | -The pill is a single 320×60 WPF window, always-on-top, click-to-focus. The +The pill is a single 320×60 WPF window, always-on-top, click-to-focus: the click goes +back to the desktop app window, to a manually pinned window, or to the +originating terminal. The widget polls `%APPDATA%\mcode-island\status.json` every 400 ms; the agent (or a thin wrapper) just writes JSON to that file. @@ -90,6 +92,35 @@ optional `-Message`. A separate `mcode-status-detect.ps1` polls the runtime's pill still moves even when the agent forgets to push. See [`SKILL.md`](skills/mcode-island/SKILL.md) for the agent-side call patterns. +#### Which session the detector follows + +**The session that wrote its log most recently.** The detector scans every +session log under the runtime's session store and picks the file with the +newest mtime, then infers the state from that file's last complete line. + +This is a deliberate decision, not an accident. The conversation the user is +looking at is the one writing to the log, so "whoever wrote last" is the +active conversation. Anything smarter — pinning a session id, asking the +runtime which window is focused — needs a signal the runtime does not expose +to a plugin, and guessing one would make the pill wrong in the common case +to make it right in the rare one. + +**Known boundary.** When several conversations run at the same time (a +desktop session plus a CLI session, or two desktop windows), the detector +follows whichever wrote last, so the pill can briefly show a neighbouring +conversation's state. The detector deliberately does not try to disambiguate +this. Two guards keep the damage small: + +- the state is re-derived on every change, so the pill corrects itself as + soon as the followed session writes again; +- a log line that cannot be parsed never falls back to another session's + message (the reader grows its window until a complete line is found, and + a read failure reports "no new information" instead of a foreign state). + +This is also why the Marketplace build needs the detector: its package ships +no Hook material, so the detector is the only source of state that does not +depend on the agent cooperating. + ## Copyable example ### One-line install and run @@ -156,6 +187,28 @@ alternative: & "%PLUGIN_DIR%\mcode-island\notify-island.ps1" -State error -Message "npm test failed" ``` +For sub-step progress (Computer Use iterative loops, multi-step plans), +pass `-Step` / `-Total` / `-Detail` so the pill shows what the agent is +doing *right now*: + +```powershell +& "%PLUGIN_DIR%\mcode-island\notify-island.ps1" -State working -Message "Computer Use" ` + -Step 3 -Total 12 -Detail "fill username field" +# → pill renders: "step 3/12 · fill username field" + +& "%PLUGIN_DIR%\mcode-island\notify-island.ps1" -State working -Message "Bash" ` + -Step 5 -Detail "npm install" +# → pill renders: "step 5 · npm install" + +& "%PLUGIN_DIR%\mcode-island\notify-island.ps1" -State done -Message "Bash ok" +# → pill renders: "Bash ok" (no step → legacy behavior, backward compat) +``` + +All three params are optional and backward compatible. The detail field +replaces the message in the rendered pill when present (avoids stacking +"Bash ok · fill username"). See `skills/mcode-island/SKILL.md` for the +full semantics and the contract with the widget renderer. + ## Quick start 1. **Install** — copy this folder into your `~/.minimax/plugins/mcode-island/` @@ -194,7 +247,7 @@ mcode-island/ ├── stop-island.ps1 # stop the widget ├── status-island.ps1 # print widget PID + recent log ├── show-island.ps1 # re-raise hidden widget -├── pin-island.ps1 # lock click-to-focus target +├── pin-island.ps1 # pin the click target to the foreground window ├── autostart.ps1 # register / unregister Windows logon ├── notify-island.ps1 # state-push helper (Mode B) ├── wrap-tool.ps1 # all-in-one bash wrapper @@ -232,7 +285,7 @@ binary, no symlink, no `node_modules`. | .NET WPF runtime | 4.x (ships with Windows 10/11) | | mcode | any version (Mode B works everywhere); 0.2.4+ activates Mode A | | execution policy | `Bypass` for this directory; not changed globally | -| network access | **optional** — see "Network access" below. The widget itself is offline. `mcode-status-detect.ps1` only contacts `https://api.minimax.io/v1/coding_plan/remains` when a token is configured (see "Accounts" + "Data use"). | +| network access | **optional** — see "Network access" below. The widget itself is offline. `mcode-status-detect.ps1` only contacts `https://api.minimaxi.com/v1/coding_plan/remains` when a token is configured (see "Accounts" + "Data use"). | | accounts | **optional** — see "Accounts" below. No account is required to run the widget; a token is only needed if you want the optional 5-hour usage readout in the pill. | | paid services | **none added by this plugin** — the 5h usage endpoint is part of the user's existing MiniMax account, not a separate service | @@ -241,33 +294,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 | +| `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 | -| `config.json` -> `planApiToken` | `%APPDATA%\mcode-island\` | until `-Clear` or manual edit | 5h usage API token (opt-in; see "Accounts") | +| `config.json` -> `planApiTokenEnc` | `%APPDATA%\mcode-island\` | until `-Clear` or manual edit | 5h usage API token, DPAPI-encrypted (opt-in; see "Accounts") | | `widget.pid` | `%APPDATA%\mcode-island\` | rewritten on start | widget process PID | | `island.log` | `%APPDATA%\mcode-island\` | append-only, never pruned | state transition history | | `widget.log` | `%APPDATA%\mcode-island\` | append-only, never pruned | widget internal debug | | `show.signal` | `%APPDATA%\mcode-island\` | transient | "raise hidden window" signal | | `HKCU\...\Run` | Windows registry | until disabled | logon auto-start | -**Telemetry: none.** **No data is sent off-machine unless the optional -`planApiToken` is configured (see "Network access" below).** The widget -itself is offline and never reads or writes anything outside `%APPDATA%\mcode-island\`. +**Telemetry: none.** **No data is sent off-machine unless a token is +configured (see "Network access" below).** The widget itself is offline +and never reads or writes anything outside `%APPDATA%\mcode-island\`. + +**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. -1. A token is configured (env `MINIMAX_OAUTH_TOKEN` or `MINIMAX_API_KEY`, - or `set-token.ps1 ` which writes to `config.json:planApiToken`). +``` +%APPDATA%\mcode-island\usage.json +``` + +```json +{ + "fetchedAtMs": 1792800000000, + "fiveHour": { "remainingPercent": 58, "resetAtMs": 1792800000000, "unlimited": false }, + "weekly": { "remainingPercent": 40, "resetAtMs": 1792900000000, "unlimited": false }, + "video": { "remainingCount": 12, "totalCount": 40 } +} +``` + +`scripts/lib/Plan-Usage.ps1` reads it. `MCODE_PLAN_USAGE_FILE` overrides +the path; `~/.minimax/usage.json` is read as a fallback. `fiveHour` is +required, `weekly` and `video` are optional. + +When that file is absent, the plugin falls back to asking the API itself, +and the only network caller in this plugin is `mcode-status-detect.ps1` +(Mode B detector). It only makes a request when ALL of the following are +true: + +1. A token is configured — either `set-token.ps1 `, which encrypts + it into `config.json:planApiTokenEnc`, or an environment variable + (`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 +378,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 +406,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 +418,118 @@ 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 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. + ## Test evidence This plugin has been exercised on Windows 11 24H2 with PowerShell 5.1, against @@ -343,6 +542,11 @@ a live MiniMax Code session. Empirical evidence (captured during development): `waiting` → `working` → `done` on a real `bash npm test` run. - Click-to-focus round-trip verified: from a Feishu tab, click the pill, focus jumps to the originating Windows Terminal tab (HWND consistent). +- Click-target order verified against the live desktop app: with + `MiniMax Inside Code` running, the click resolves to the app window + (`mode=focus`, never hidden); with a `pin.json` present it resolves to the + pinned window; with neither, it falls back to the caller's terminal with + the original show/hide toggle (`mode=toggle`). - `wrap-tool.ps1` exit-code semantics: 0 → `done`, 1 → `waiting` (default; configurable via `-WaitingExitCodes`), other → `error`. As of v0.2.1 the wrapper no longer executes the command itself; the agent runs the command diff --git a/plugins/antianqi/mcode-island/autostart.ps1 b/plugins/antianqi/mcode-island/autostart.ps1 index 17b1a9af..360cd276 100644 --- a/plugins/antianqi/mcode-island/autostart.ps1 +++ b/plugins/antianqi/mcode-island/autostart.ps1 @@ -32,13 +32,39 @@ function Build-Command([string]$Launcher) { switch ($Action) { 'Enable' { - New-Item -Path $runKey -Force | Out-Null + # 只在 Run 键不存在时创建它。 + # + # 之前这里是无条件 New-Item -Path $runKey -Force:对一个已经存在的 + # HKCU\...\Run 键,-Force 会重建这个键,于是把用户注册在它下面的 + # 其它开机自启项(Everything / OneDrive / 各种代理客户端 …)一起清掉。 + # 现在只在键缺失时创建,并且用 New-ItemProperty 只写自己的那一个值, + # 绝不碰同键下的其它值。 + if (-not (Test-Path $runKey)) { + New-Item -Path $runKey -Force | Out-Null + } + + # 记录同键下的其它值,写完再核对一遍,确保一个都没少。 + $othersBefore = @((Get-Item -Path $runKey -ErrorAction SilentlyContinue).GetValueNames() | + Where-Object { $entries.Keys -notcontains $_ }) + foreach ($name in $entries.Keys) { $cmd = Build-Command $entries[$name] - Set-ItemProperty -Path $runKey -Name $name -Value $cmd + # -Force 只影响这一个具名值:不存在则建,存在则覆盖。 + New-ItemProperty -Path $runKey -Name $name -Value $cmd -PropertyType String -Force | Out-Null Write-Output "ENABLED: $runKey\$name" Write-Output " Value: $cmd" } + + $othersAfter = @((Get-Item -Path $runKey -ErrorAction SilentlyContinue).GetValueNames() | + Where-Object { $entries.Keys -notcontains $_ }) + $lost = @($othersBefore | Where-Object { $othersAfter -notcontains $_ }) + if ($lost.Count -gt 0) { + Write-Output "ERROR: unrelated Run entries disappeared: $($lost -join ', ')" + exit 1 + } + if ($othersAfter.Count -gt 0) { + Write-Output " (kept $($othersAfter.Count) unrelated Run entr(y|ies))" + } } 'Disable' { $any = $false diff --git a/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/_lib.ps1 b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/_lib.ps1 index 797813ba..303313e1 100644 --- a/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/_lib.ps1 +++ b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/_lib.ps1 @@ -1,4 +1,4 @@ -# mcode-island: shared library for io.minimax.mcode Hooks scripts. +# mcode-island: shared library for io.minimax.mcode Hooks scripts. # Loaded via dot-source at the top of each event script: # . "$PSScriptRoot\_lib.ps1" # All event scripts under this directory MUST exit 0 (or 2 with a stderr @@ -12,6 +12,10 @@ $ErrorActionPreference = 'Stop' $script:PluginRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..\..')).Path $script:NotifyIsland = Join-Path $script:PluginRoot 'notify-island.ps1' +# Shared redaction helper (round-20). Lives in a lib because the detector +# is a second, independent producer of the same text and must redact too. +. (Join-Path $script:PluginRoot 'scripts\lib\Protect-Text.ps1') + function Set-ConsoleUtf8 { # Force UTF-8 so the PowerShell child that mcode spawns reads the # stdin JSON cleanly. notify-island.ps1 also does this internally, @@ -43,7 +47,10 @@ function Push-Island { [ValidateSet('idle','thinking','working','waiting','done','error')] [string]$State, - [string]$Message = '' + [string]$Message = '', + [int]$Step = -1, + [int]$Total = -1, + [string]$Detail = '' ) if (-not (Test-Path -LiteralPath $script:NotifyIsland)) { # Widget is not installed yet — silent no-op. The plugin's @@ -52,7 +59,7 @@ function Push-Island { return } try { - & $script:NotifyIsland -State $State -Message $Message 2>$null | Out-Null + & $script:NotifyIsland -State $State -Message $Message -Step $Step -Total $Total -Detail $Detail 2>$null | Out-Null } catch { # Hook must never block the agent on a notification failure. } @@ -78,6 +85,53 @@ function Test-IsSelfPush { return ($cmd -match 'notify-island\.ps1|wrap-tool\.ps1|island\\notify|island\\wrap') } +function Test-ToolResultError { + # Classifies a PostToolUse tool_result as a failure or a success. + # + # The detector already reads the structured boolean out of the session + # log (`$msg.isError -eq $true`). The hook path used to stringify the + # result and look for a textual error prefix, so a structured + # `{ isError: true }` came out as the string + # "@{isError=True; content=...}" -- which matches no prefix, so a + # FAILED tool was reported as a success and the pill showed green. + # + # Order matters. The structured boolean wins, because it is what the + # producer actually set. The textual heuristic is only a fallback for + # a result that arrives as a bare string. + param($Result) + + # No result at all: the tool produced nothing, which is a failure. + if ($null -eq $Result) { return $true } + + # A bare boolean is the flag itself. + if ($Result -is [bool]) { return $Result } + + # A structured result: read the flag the producer set. + if ($Result -isnot [string]) { + $flag = $Result.PSObject.Properties['isError'] + if ($flag) { return [bool]$flag.Value } + } + + $s = [string]$Result + if ([string]::IsNullOrEmpty($s)) { return $true } + + # A result that is a JSON string may still carry the structured flag. + $trimmed = $s.Trim() + if ($trimmed.StartsWith('{')) { + try { + $parsed = $trimmed | ConvertFrom-Json -ErrorAction Stop + if ($parsed -and $parsed.PSObject.Properties['isError']) { + return [bool]$parsed.isError + } + } catch { + # Not JSON after all. Fall through to the textual heuristic. + } + } + + if ($s -match '^\s*(Error|ERROR|✕|Error:|\[ERROR\])') { return $true } + return $false +} + function Format-ToolSummary { # Compact ": " used in pill messages. # Truncated to keep the WPF label single-line. @@ -97,10 +151,47 @@ function Format-ToolSummary { 'WebSearch' { $detail = [string]$Event.tool_input.query } 'Task' { $detail = [string]$Event.tool_input.description } 'NotebookEdit' { $detail = [string]$Event.tool_input.notebook_path } + # mcode-internal: Computer Use 抽 action + coordinate。 + # 例: "mcode-computer-use : click at (1024,768)" + # 注意:coordinate 是 array,PowerShell 默认 $OFS=' ' 会让 + # "$coord" 渲染成 "(1024 768)" 不是 "(1024,768)"。必须 + # 显式 -join ','。' ' 在 pill 上看起来像数字被截断, + # 影响用户判断坐标。 + # + # SECURITY: `tool_input.text` is whatever the user typed. It + # reaches this function verbatim, and its output is written to + # status.json, the append-only island.log, and rendered on an + # always-on-top pill. That surface is shared-screen visible, so + # any password / token / verification code typed through Computer + # Use ends up in a screenshot, a screen share, or a screen + # recording. Never echo the raw text. Report the action and a + # length only, so the pill still answers "is the agent typing?" + # without carrying the secret. + 'mcode-computer-use' { + $act = if ($Event.tool_input.action) { [string]$Event.tool_input.action } else { '' } + if ($Event.tool_input.coordinate) { + $coord = $Event.tool_input.coordinate + $coordStr = "($($coord -join ','))" + $detail = "$act at $coordStr" + } elseif ($Event.tool_input.text) { + # Length only. No substring, no length bucketing that + # could leak content shape, no echo of the value. + $len = ([string]$Event.tool_input.text).Length + $detail = "$act <$len chars, redacted>" + } else { + $detail = $act + } + } default { $detail = '' } } } if ([string]::IsNullOrEmpty($detail)) { return $tool } + # Redact before collapsing/truncating (round-20). This is the single + # choke point for every tool branch above: the result is written to + # status.json, appended to island.log, AND rendered on the pill, so + # redacting here covers all three sinks at once. Without it a Bash + # command like `export API_KEY=sk-...` reached all three verbatim. + $detail = Protect-SecretText $detail # Collapse newlines, take first 80 chars. $detail = ($detail -replace "[\r\n]+", ' ').Trim() if ($detail.Length -gt 80) { $detail = $detail.Substring(0, 77) + '...' } diff --git a/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/post-tool-use.ps1 b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/post-tool-use.ps1 index 034a31e5..11cc8242 100644 --- a/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/post-tool-use.ps1 +++ b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/post-tool-use.ps1 @@ -1,28 +1,40 @@ -# Hook: PostToolUse +# Hook: PostToolUse # Event: io.minimax.mcode / PostToolUse # State: done / error -# Note: Fires after every tool call returns. Heuristic: if the -# tool_result is empty or matches an error pattern, push -# error; otherwise push done. Self-push calls are filtered. +# Note: Fires after every tool call returns. The structured `isError` +# flag on the tool_result decides; a bare string falls back to a +# textual error prefix. Self-push calls are filtered. +# Per-tool summary (Format-ToolSummary) is split into +# Message=" ok|failed" and Detail=, so the pill +# renders "Bash ok · ls -la /tmp" instead of just "Bash ok". . "$PSScriptRoot\_lib.ps1" $evt = Read-HookStdin if (Test-IsSelfPush $evt) { exit 0 } $tool = if ($evt.tool_name) { [string]$evt.tool_name } else { 'tool' } -$result = $evt.tool_result -$isError = $false +$isError = Test-ToolResultError $evt.tool_result -if ($null -eq $result) { - $isError = $true -} else { - $s = [string]$result - if ([string]::IsNullOrEmpty($s)) { $isError = $true } - elseif ($s -match '^\s*(Error|ERROR|✕|Error:|\[ERROR\])') { $isError = $true } +# Format-ToolSummary 抽 detail,但要剥掉 "tool : " 前缀,只留后半段 +$summary = Format-ToolSummary $evt +$detail = '' +if ($summary -and $summary.StartsWith("$tool : ")) { + $detail = $summary.Substring($tool.Length + 3) +} elseif ($summary -and $summary -ne $tool) { + $detail = $summary } +# Build-DisplayMessage 只在 Step > 0 时才用 detail(Step<=0 直接返回 +# Message),所以只传 -Detail 的话 detail 永远不显示 +#(round-19 review hetaoBackend #4)。这里给一个确定的 Step/Total, +# 渲染成 "step 1/1 · "。 +# PostToolUse 是单次工具结果,不参与多步序列,所以用 1/1 而不是 +# 猜一个更大的分母——猜错会让 pill 显示 "step 1/0"。 +$step = 1 +$total = 1 + if ($isError) { - Push-Island -State error -Message "$tool failed" + Push-Island -State error -Message "$tool failed" -Detail $detail -Step $step -Total $total } else { - Push-Island -State done -Message "$tool ok" + Push-Island -State done -Message "$tool ok" -Detail $detail -Step $step -Total $total } exit 0 diff --git a/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/pre-compact.ps1 b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/pre-compact.ps1 index 8e4460fb..e1b8b2df 100644 --- a/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/pre-compact.ps1 +++ b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/pre-compact.ps1 @@ -1,4 +1,4 @@ -# Hook: PreCompact +# Hook: PreCompact # Event: io.minimax.mcode / PreCompact # State: thinking # Note: Fires before the runtime compresses context. We push diff --git a/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/session-end.ps1 b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/session-end.ps1 index 214f5629..c82be00a 100644 --- a/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/session-end.ps1 +++ b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/session-end.ps1 @@ -1,4 +1,4 @@ -# Hook: SessionEnd +# Hook: SessionEnd # Event: io.minimax.mcode / SessionEnd # State: idle # Note: Fires when the runtime terminates a session. We push idle diff --git a/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/stop.ps1 b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/stop.ps1 index 0637c436..c75d7026 100644 --- a/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/stop.ps1 +++ b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/stop.ps1 @@ -1,4 +1,4 @@ -# Hook: Stop +# Hook: Stop # Event: io.minimax.mcode / Stop # State: done # Note: Fires when the agent finishes a turn (one model response, diff --git a/plugins/antianqi/mcode-island/mcode-island.cmd b/plugins/antianqi/mcode-island/mcode-island.cmd index 2720cda7..a33ba6e9 100644 --- a/plugins/antianqi/mcode-island/mcode-island.cmd +++ b/plugins/antianqi/mcode-island/mcode-island.cmd @@ -47,7 +47,7 @@ exit /b 0 exit /b %errorlevel% :unpin -%PS% -Command "Remove-Item -Path (Join-Path $env:APPDATA 'mcode-island\caller.json') -ErrorAction SilentlyContinue; Write-Output 'unpinned: caller.json cleared'" +%PS% -Command "Remove-Item -Path (Join-Path $env:APPDATA 'mcode-island\pin.json'),(Join-Path $env:APPDATA 'mcode-island\caller.json') -ErrorAction SilentlyContinue; Write-Output 'unpinned: pin.json and caller.json cleared'" exit /b %errorlevel% :autostart_on diff --git a/plugins/antianqi/mcode-island/mcode-island.ps1 b/plugins/antianqi/mcode-island/mcode-island.ps1 index 898322a7..caed63e3 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)" @@ -71,11 +96,66 @@ 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); + [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) { @@ -132,7 +212,14 @@ $xaml = @' Topmost="True" ShowInTaskbar="False" ResizeMode="NoResize" WindowStartupLocation="Manual" UseLayoutRounding="True" SnapsToDevicePixels="True" Focusable="False" ShowActivated="False"> - + + + + + + + @@ -218,6 +305,23 @@ $xaml = @' VerticalAlignment="Center"/> + + + + + + '@ @@ -248,6 +352,12 @@ $progressShimmer = $window.FindName('ProgressShimmer') $progressShimmerTransform = $window.FindName('ProgressShimmerTransform') $elapsedText = $window.FindName('ElapsedText') $usage5hText = $window.FindName('Usage5hText') +# 多 session 列表(画在 pill 自己的窗口里) +$script:sessionRowsPanel = $window.FindName('SessionPanel') +$script:sessionRows = $window.FindName('SessionRows') +$script:sessionListRow = $window.FindName('SessionListRow') +$script:pillHeight = 70 +$script:listHideTimer = $null # 状态配色 $stateMap = @{ @@ -259,6 +369,848 @@ $stateMap = @{ error = @{ dot='#FFEF4444'; ring='#FFEF4444'; 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 @() + } +} + +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 可能还是 + # 旧的。 + $stateTextMap = @{ + 'working' = @{ text = '运行中'; dot = '#FF3B82F6' } + 'thinking' = @{ text = '思考中'; dot = '#FFEAB308' } + 'waiting' = @{ text = '等待审批'; dot = '#FFF59E0B' } + 'idle' = @{ text = '待回复'; dot = '#FF6B7280' } + 'done' = @{ text = '已完成'; dot = '#FF22C55E' } + 'error' = @{ text = '出错'; dot = '#FFEF4444' } + 'stopped' = @{ text = '已停止'; dot = '#FF4B5563' } + 'running' = @{ text = '运行中'; dot = '#FF3B82F6' } + } + $stateInfo = $stateTextMap[[string]$phase] + if (-not $stateInfo) { $stateInfo = $stateTextMap['idle'] } + $dotHex = $stateInfo.dot + $phaseText = $stateInfo.text + + # 第二行:正文优先。没有正文(最后一条恰好是 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) + $grid.ColumnDefinitions.Add($colDot) | Out-Null + $grid.ColumnDefinitions.Add($colTitle) | Out-Null + $grid.ColumnDefinitions.Add($colTag) | 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 = 7; $dot.Height = 7 + $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,9,0) + + $txt = New-Object System.Windows.Controls.TextBlock + $txt.Text = if ($title) { $title } else { $id } + $txt.Foreground = (New-Object System.Windows.Media.SolidColorBrush ([System.Windows.Media.ColorConverter]::ConvertFromString('#FFF5F5F7'))) + $txt.FontSize = 12.5 + $txt.TextTrimming = [System.Windows.TextTrimming]::CharacterEllipsis + $txt.VerticalAlignment = [System.Windows.VerticalAlignment]::Center + + $sub = New-Object System.Windows.Controls.TextBlock + $sub.Text = $subText + $sub.Foreground = (New-Object System.Windows.Media.SolidColorBrush ([System.Windows.Media.ColorConverter]::ConvertFromString('#FF8B93A1'))) + $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 = (New-Object System.Windows.Media.SolidColorBrush ([System.Windows.Media.ColorConverter]::ConvertFromString('#FF9CA3AF'))) + $time.FontSize = 10 + $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 = 10 + $tag.HorizontalAlignment = [System.Windows.HorizontalAlignment]::Right + + $right.Children.Add($time) | Out-Null + $right.Children.Add($tag) | Out-Null + + $grid.Children.Add($dot) | Out-Null + $grid.Children.Add($txt) | Out-Null + $grid.Children.Add($right) | Out-Null + $grid.Children.Add($sub) | 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]::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 + 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 = (New-Object System.Windows.Media.SolidColorBrush ([System.Windows.Media.ColorConverter]::ConvertFromString('#FF8B93A1'))) + $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 } + 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 14 + Padding 12 + Border 2。两行时内容需要 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,所以自己加上 SessionPanel 的 14px 上边距。 + $listH = [int][Math]::Ceiling($script:sessionRowsPanel.DesiredSize.Height) + 14 + 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 + if ($maxH -gt $script:pillHeight -and ($script:pillHeight + $listH) -gt $maxH) { + $listH = [int][Math]::Floor($maxH) - $script:pillHeight + Dbg ("session list clamped to the work area: listH=" + $listH) + } + } + } catch {} + $window.Height = $script:pillHeight + $listH + 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 } +} + +# 宽限期之后才收起。鼠标从药丸移到列表会经过一段"两个窗口都不是"的地带, +# 没有宽限期列表会在鼠标到达之前就消失。 +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 { + $ackFile = Join-Path $configDir 'ack.signal' + [System.IO.File]::WriteAllText($ackFile, $id, [System.Text.Encoding]::UTF8) + 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") +} + +# 找到 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)。plan 早先用的是 #8B5CF6,和 search 的 #A855F7 在深色 +# pill 上几乎分不开,换成去饱和的灰蓝。 +$familyMap = @{ + shell = '#FF22C55E' # bash / shell → 绿 + read = '#FF3B82F6' # read → 蓝 + write = '#FFEAB308' # edit / write → 黄 + search = '#FFA855F7' # grep / glob → 紫 + task = '#FF06B6D4' # task → 青 + web = '#FFEC4899' # web_search/fetch → 粉 + plan = '#FF94A3B8' # todowrite → 灰蓝 +} + # 颜色转 brush function C($hex) { return (New-Object System.Windows.Media.SolidColorBrush([System.Windows.Media.ColorConverter]::ConvertFromString($hex))) } @@ -370,6 +1322,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 +1357,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 +1374,26 @@ function Update-State { [int]$Progress = -1, [int]$Usage5h = -2, [int]$Usage5hResetMs = 0, - [int]$TodoProgress = -2 + [int]$TodoProgress = -2, + [int]$Step = -1, + [int]$Total = -1, + [string]$Detail = '', + [string]$Family = '' ) $s = $script:stateMap[$State] if (!$s) { $s = $script:stateMap['idle'] } - $script:statusDot.Fill = C $s.dot - $script:pulseRing.Fill = C $s.ring + # 家族色只作用于 active 态(thinking/working/waiting),这样 done 绿 / error 红 + # 依然是"结果"语义,家族色只用来区分"在做什么"。 + $dotHex = $s.dot + $ringHex = $s.ring + if ($Family -and $State -in @('thinking','working','waiting')) { + $famHex = $script:familyMap[$Family] + if ($famHex) { $dotHex = $famHex; $ringHex = $famHex } + } + $script:statusDot.Fill = C $dotHex + $script:pulseRing.Fill = C $ringHex $script:stateText.Text = $s.label - $script:messageText.Text = if ($Message) { $Message } else { '' } + $script:messageText.Text = Build-DisplayMessage -Message $Message -Step $Step -Total $Total -Detail $Detail $script:actionIcon.Text = $s.icon if ($State -in @('thinking','working','waiting')) { Start-Pulse } else { Stop-Pulse } @@ -447,20 +1447,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 +1477,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 - } - # 3) 设顶 - [WinAPI]::SetWindowPos($hwnd, [WinAPI]::HWND_TOPMOST, 0, 0, 0, 0, [WinAPI]::SWP_NOACTIVATE) | Out-Null - [WinAPI]::SetWindowPos($hwnd, [IntPtr]::new(-2), 0, 0, 0, 0, [WinAPI]::SWP_NOACTIVATE) | Out-Null # HWND_NOTOPMOST - # 4) 抢焦点 - [WinAPI]::BringWindowToTop($hwnd) | Out-Null - [WinAPI]::SetForegroundWindow($hwnd) | Out-Null - $proc2 = Get-Process -Id $targetPid -ErrorAction SilentlyContinue - Dbg ("FOCUS OK: target=" + $proc2.ProcessName + " PID=" + $targetPid + " hwnd=" + $hwnd) + [WinAPI]::AllowSetForegroundWindow([uint32]$Target.Pid) | Out-Null + # 只在窗口真的"看不见"时才动它的显示状态: + # minimized -> SW_RESTORE(还原) + # hidden -> SW_SHOW(显示) + # 已经可见的窗口一个字节都不碰 —— 对一个已经最大化的窗口再调一次 + # SW_SHOW 没有意义,只是白白多一次状态抖动。 + if ([WinAPI]::IsIconic($Target.Hwnd)) { + [WinAPI]::ShowWindow($Target.Hwnd, 9) | Out-Null # SW_RESTORE + } elseif (-not [WinAPI]::IsWindowVisible($Target.Hwnd)) { + [WinAPI]::ShowWindow($Target.Hwnd, 5) | Out-Null # SW_SHOW + } + # TOPMOST -> NOTOPMOST: it lets BringWindowToTop push the window all the + # way up without leaving a "always on top" side effect behind. + # + # SWP_NOSIZE / SWP_NOMOVE 是必须的,不是锦上添花:这里 cx=0/cy=0/ + # X=0/Y=0 的意思是"大小和位置都不变",但这个意思**只在 flag 齐全时 + # 才成立**。少了 SWP_NOSIZE,Windows 会把它当成"resize 到 0x0"—— + # Windows Terminal 有 min-size 兜底,于是窗口塌成 480x84 的一条; + # 少了 SWP_NOMOVE 同理,窗口会被真的挪到 (0,0) 左上角。 + # + # 同一文件里 Toggle-Window 的注释早就写明了这个坑,但 Focus-Window + # 一直漏着,直到会话点击真的走到这里才暴露出来。 + $nofollow = [WinAPI]::SWP_NOACTIVATE -bor [WinAPI]::SWP_NOSIZE -bor [WinAPI]::SWP_NOMOVE + [WinAPI]::SetWindowPos($Target.Hwnd, [WinAPI]::HWND_TOPMOST, 0, 0, 0, 0, $nofollow) | Out-Null + [WinAPI]::SetWindowPos($Target.Hwnd, [IntPtr]::new(-2), 0, 0, 0, 0, $nofollow) | Out-Null # HWND_NOTOPMOST + [WinAPI]::BringWindowToTop($Target.Hwnd) | Out-Null + [WinAPI]::SetForegroundWindow($Target.Hwnd) | Out-Null + # "DONE" 而不是 "OK":这里只说明这几个调用没抛异常,不代表窗口真到了 + # 前台。到底到没到,看 Test-ForegroundPid 的回读。 + Dbg ("FOCUS DONE: target=" + $Target.Exe + " PID=" + $Target.Pid + " hwnd=" + $Target.Hwnd) } catch { Dbg "FOCUS FAIL: $($_.Exception.Message)" } } +# 兼容旧调用点:自动聚焦流程(needs_input 状态自动弹窗那种)用调用方窗口。 +function Focus-CallerWindow { + $r = Resolve-CallerWindow + if (-not $r) { return } + Focus-Window $r +} + +# 单击 pill toggle:可见 → 隐藏;隐藏 → 全屏还原 + 抢焦点。 +# 设计取舍 (round-14+15+16): +# hide 分支用 SW_HIDE (而不是 SW_MINIMIZE): +# SW_MINIMIZE 在某些终端配置下(Windows Terminal "Always show tabs on top") +# 会保留一个 thin tab-bar strip 浮在桌面顶部,不算真"藏"。 +# show 分支先 SW_MAXIMIZE (激活+最大化),再用 SetWindowPos 强制拉到 +# MonitorFromWindow+GetMonitorInfo 拿到的真实 work area(2560x1392 而 +# 不是 [Screen]::PrimaryScreen 报告的 1920x1080 — WinForms DPI 虚拟化 +# 会把 2560x1440 物理像素报成 1920x1080 逻辑像素,SW_MAXIMIZE 跟着 +# 1920x1080 走,结果 WT 只填了物理显示器的左上 75%)。 +# 最后 SetWindowPos(HWND_TOP) + BringWindowToTop 抢 z-order,绕过 +# widget PID 没有 foreground 权限的限制。 +# 状态判定: IsWindowVisible 在 SW_HIDE 和 SW_MINIMIZE 后都返回 false +# (区别是 IsIconic:SW_HIDE 后 false,SW_MINIMIZE 后 true)。toggle 只看 +# IsWindowVisible 即可,SW_MAXIMIZE 在内部正确处理两种 case。 +function Toggle-Window { + param([Parameter(Mandatory = $true)]$Target) + + try { + $isShown = [WinAPI]::IsWindowVisible($Target.Hwnd) + if ($isShown) { + [WinAPI]::ShowWindow($Target.Hwnd, 0) | Out-Null # SW_HIDE + Dbg "TOGGLE: hid target=$($Target.Exe) PID=$($Target.Pid) hwnd=$($Target.Hwnd)" + } else { + # 1)+2) SW_MAXIMIZE 激活+标记 maximized,再用 SetWindowPos 强制拉到 + # monitor work area(绕过 DPI/remembered-size 限制;细节见 + # Show-WindowFullScreen 的注释) + $wa = Show-WindowFullScreen $Target.Hwnd + if ($wa) { + # The -f has to sit inside the parentheses: written as + # `Dbg "..." -f a, b` the format operator binds to the command, not to + # the string, and the log line keeps the raw {0} {1} placeholders. + Dbg ("TOGGLE: forced to work area ({0},{1}) {2}x{3}" -f $wa.Left, $wa.Top, $wa.W, $wa.H) + } + # 3) 抢 z-order 到最前(SetWindowPos(HWND_TOP) 不需要 foreground 权限) + # SWP_NOSIZE:cx=0/cy=0 缺它会被 Windows 当成"resize 到 0x0", + # 触发 WT 的 min-size 兜底,变成 480x76 strip。 + # SWP_NOMOVE:X=0/Y=0 缺它会把窗口真的挪到 (0,0)。单屏时无所谓, + # 但副屏(monitor 原点非 0)上会把还原后的窗口甩到主屏左上角。 + # 同样不能加 SWP_NOZORDER——那个 flag 会让 hWndInsertAfter 被忽略, + # HWND_TOP 就白传了,BringWindowToTop 之后仍可能被别的窗口盖住。 + [WinAPI]::AllowSetForegroundWindow([uint32]$Target.Pid) | Out-Null + $nofollow = [WinAPI]::SWP_NOACTIVATE -bor [WinAPI]::SWP_NOSIZE -bor [WinAPI]::SWP_NOMOVE + [WinAPI]::SetWindowPos($Target.Hwnd, [WinAPI]::HWND_TOP, 0, 0, 0, 0, $nofollow) | Out-Null + [WinAPI]::BringWindowToTop($Target.Hwnd) | Out-Null + [WinAPI]::SetForegroundWindow($Target.Hwnd) | Out-Null + Dbg "TOGGLE: shown (maximized + work-area) target=$($Target.Exe) PID=$($Target.Pid) hwnd=$($Target.Hwnd)" + } + } catch { + Dbg "TOGGLE FAIL: $($_.Exception.Message)" + } +} + +# 兼容旧调用点。 +function Toggle-CallerWindow { + $r = Resolve-CallerWindow + if (-not $r) { return } + Toggle-Window $r +} + +# The single entry point for a pill click: resolve the target, then run the +# mode it resolved to. focus (manual pin / desktop app) and toggle (the +# caller's terminal) are two different semantics -- do not mix them. +function Invoke-ClickTarget { + $t = Resolve-ClickTarget + if (-not $t) { Dbg 'CLICK: no target resolved'; return } + Dbg "CLICK: target=$($t.Exe) PID=$($t.Pid) hwnd=$($t.Hwnd) mode=$($t.Mode) origin=$($t.Origin)" + if ($t.Mode -eq 'focus') { + Focus-Window $t + } else { + Toggle-Window $t + } +} + # 手动设置焦点目标(右键菜单调用):把当前前台窗口记为 focus target function Set-FocusTarget-Current { Add-Type @" @@ -575,7 +1855,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 +1868,7 @@ public class FG2 { source = 'manual-pin' ts = (Get-Date).ToString('o') } | ConvertTo-Json -Compress - [System.IO.File]::WriteAllText($callerFile, $payload, [System.Text.Encoding]::UTF8) + [System.IO.File]::WriteAllText($pinFile, $payload, [System.Text.Encoding]::UTF8) Dbg ("PIN: target=" + $proc.ProcessName + " PID=" + $proc.Id + " hwnd=" + $hwnd) } @@ -609,6 +1891,50 @@ function Flash-Click { $sb.Begin($window) } +# Keep the pill above everything else. +# +# Why this exists: the only thing that ever made the pill topmost is +# Topmost="True" in the XAML, evaluated once at window creation. Every other +# SetWindowPos in this file targets the *caller* window, not the pill. So once +# anything else lands above the pill -- a maximized terminal being activated, a +# fullscreen app, another always-on-top tool -- the pill stays buried forever. +# WS_EX_TOPMOST is a persistent style but only orders the pill against the +# topmost band; whichever topmost window was raised last wins, and a plain +# maximized window that got re-shown can end up painting over it too. +# Observed 2026-09-28: pill fully invisible behind Windows Terminal while +# IsWindowVisible=1, IsIconic=0, DWMWA_CLOAKED=0, widget still polling. +# +# Fix: hit-test the pill centre on a throttle and re-assert HWND_TOPMOST when +# something else owns that pixel. SWP_NOACTIVATE means this never steals focus +# from the user's terminal. Skipped entirely while the pill is hidden so it +# cannot undo a user-initiated hide. "mcode-island show" passes -Force because +# $window.Show() is a no-op on an already-visible WPF window and therefore +# never restored z-order on its own. +$script:lastTopmostCheck = [DateTime]::MinValue +function Assert-Topmost { + param([switch]$Force) + if (-not $Force -and + [DateTime]::UtcNow - $script:lastTopmostCheck -lt [TimeSpan]::FromMilliseconds(1500)) { + return + } + $script:lastTopmostCheck = [DateTime]::UtcNow + try { + $helper = New-Object System.Windows.Interop.WindowInteropHelper($window) + $h = $helper.Handle + if (-not $h -or -not [WinAPI]::IsWindow($h)) { return } + # Never resurrect a pill the user hid. + if (-not [WinAPI]::IsWindowVisible($h)) { return } + if ($Force -or [WinAPI]::IsPillCovered($h)) { + $flags = [WinAPI]::SWP_NOACTIVATE -bor [WinAPI]::SWP_NOMOVE -bor ` + [WinAPI]::SWP_NOSIZE -bor [WinAPI]::SWP_SHOWWINDOW + [WinAPI]::SetWindowPos($h, [WinAPI]::HWND_TOPMOST, 0, 0, 0, 0, $flags) | Out-Null + Dbg "TOPMOST: re-raised (forced=$([bool]$Force))" + } + } catch { + Dbg "TOPMOST FAIL: $($_.Exception.Message)" + } +} + # 窗口定位 $screen = [System.Windows.SystemParameters]::WorkArea if ($cfg.x -lt 0) { @@ -620,6 +1946,13 @@ if ($cfg.x -lt 0) { } $window.Width = $cfg.width $window.Height = $cfg.height +# 药丸高度锚点。row 0 锁死成这个高度,所以展开 session 列表时窗口变高、 +# 药丸本身一动不动,列表在它下面长出来。 +$script:pillHeight = $window.Height +try { + $pillRow = $window.FindName('PillRow') + if ($pillRow) { $pillRow.Height = New-Object System.Windows.GridLength($script:pillHeight) } +} catch {} $window.Opacity = $cfg.opacity # 右键被禁用(用 CLI 'mcode-island pin' 替代,更稳) @@ -629,31 +1962,105 @@ $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) { + 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)" } }) $window.Add_MouseLeftButtonUp({ + try { $window.ReleaseMouseCapture() } catch {} try { if ($script:dragStart -and -not $script:didDrag) { Dbg 'CLICK detected' Flash-Click - Focus-CallerWindow + Invoke-ClickTarget } } catch { Dbg "CLICK FAIL: $($_.Exception.Message)" @@ -662,6 +2069,19 @@ $window.Add_MouseLeftButtonUp({ $script:didDrag = $false }) +# 多 session 列表的展开/收起。 +# +# 鼠标停在 pill 上 -> 展开;离开 -> 宽限期后收起。列表是独立窗口,鼠标从 +# pill 走到列表会经过一段"两个窗口都不在"的地带,所以要留宽限期,鼠标 +# 进列表窗口就取消。 +$window.Add_MouseEnter({ + if ($script:listHideTimer) { try { $script:listHideTimer.Stop() } catch {} ; $script:listHideTimer = $null } + try { Show-SessionList } catch { Dbg "SESSION LIST SHOW FAIL: $($_.Exception.Message)" } +}) +$window.Add_MouseLeave({ + Schedule-SessionListHide +}) + # FileSystemWatcher 监听 status.json(轮询版本,FSW 在 WPF 应用里经常抽风) $script:lastStatusMtime = $null @@ -670,6 +2090,8 @@ $showSignalFile = Join-Path $configDir 'show.signal' $timer = New-Object System.Windows.Threading.DispatcherTimer $timer.Interval = [TimeSpan]::FromMilliseconds(400) $timer.Add_Tick({ + # 0) 自愈:被别的窗口压住就重新置顶(内部 1.5s 节流) + Assert-Topmost # 1) 处理 "show" 信号("mcode-island show" 创建这个文件) if (Test-Path $showSignalFile) { try { Remove-Item $showSignalFile -Force -ErrorAction SilentlyContinue } catch {} @@ -677,6 +2099,7 @@ $timer.Add_Tick({ $window.Show() $window.WindowState = [System.Windows.WindowState]::Normal $window.Activate() + Assert-Topmost -Force Dbg 'SHOWN via signal file' }) } @@ -689,18 +2112,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 +2150,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,6 +2176,8 @@ $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 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..ca111862 --- /dev/null +++ b/plugins/antianqi/mcode-island/scripts/lib/Session-Tracker.ps1 @@ -0,0 +1,803 @@ +# --------------------------------------------------------------------------- +# 多 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。 +function Read-AckSignal { + $ackFile = Join-Path $configDir 'ack.signal' + if (!(Test-Path -LiteralPath $ackFile)) { return $null } + $id = $null + try { $id = (Get-Content -LiteralPath $ackFile -Raw -Encoding UTF8) } catch { $id = $null } + try { Remove-Item -LiteralPath $ackFile -Force -ErrorAction SilentlyContinue } catch {} + if ($id) { return $id.Trim() } + return $null +} + +# 每轮更新一次 session 列表。 +function Update-SessionTracker { + # 用户点掉的先消化掉 + $acked = Read-AckSignal + if ($acked) { + 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..8ce29d30 100644 --- a/plugins/antianqi/mcode-island/scripts/smoke.mjs +++ b/plugins/antianqi/mcode-island/scripts/smoke.mjs @@ -23,7 +23,7 @@ // 6. cross-platform: no hardcoded host-absolute paths, no // /Users/ or /home/ literals in any script or hooks.json entry -import { readFile, stat } from 'node:fs/promises'; +import { readFile, stat, readdir } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import { dirname, join, resolve, sep } from 'node:path'; @@ -155,7 +155,7 @@ const checkEntry = async (event, entry) => { }; const main = async () => { - console.log(`mcode-island v0.3.0 self-check`); + console.log(`mcode-island v0.5.0 self-check`); console.log(`plugin root: ${PLUGIN_ROOT}`); console.log('-'.repeat(60)); @@ -182,8 +182,8 @@ const main = async () => { } else { out('PASS', `plugin.json: name is "${plugin.name}"`); } - if (plugin.version !== '0.3.0') { - out('FAIL', `plugin.json: version is "${plugin.version}", expected "0.3.0"`); + if (plugin.version !== '0.5.0') { + out('FAIL', `plugin.json: version is "${plugin.version}", expected "0.5.0"`); } else { out('PASS', `plugin.json: version is "${plugin.version}"`); } @@ -338,6 +338,839 @@ const main = async () => { } } + // 5c1. Sub-step progress extension (round-13 refactor). + // notify-island.ps1 must accept -Step/-Total/-Detail and write them + // into status.json. mcode-island.ps1 widget must define + // Build-DisplayMessage and pass step/total/detail to it. + // _lib.ps1 Format-ToolSummary must extract mcode-computer-use + // action+coordinate; Push-Island must forward the new fields. + // The detailed functional tests live in test-substep-progress.mjs; + // here we lock the surface contract so a future refactor that + // drops the params surfaces in smoke (fast path) before reaching + // the slower pwsh-spawned tests. + const substepNotifyPath = join(PLUGIN_ROOT, 'notify-island.ps1'); + const substepWidgetPath = join(PLUGIN_ROOT, 'mcode-island.ps1'); + if (!(await exists(substepNotifyPath))) { + out('FAIL', 'notify-island.ps1 missing (sub-step lock skipped)'); + } else { + const notify = await readFile(substepNotifyPath, 'utf8'); + if (!/\[int\]\$Step\s*=\s*-1/.test(notify) || + !/\[int\]\$Total\s*=\s*-1/.test(notify) || + !/\[string\]\$Detail\s*=\s*''/.test(notify)) { + out('FAIL', 'notify-island.ps1: missing -Step/-Total/-Detail params'); + } else { + out('PASS', 'notify-island.ps1: declares -Step -Total -Detail'); + } + if (!/step\s*=\s*\$Step/.test(notify) || + !/total\s*=\s*\$Total/.test(notify) || + !/detail\s*=\s*\$Detail/.test(notify)) { + out('FAIL', 'notify-island.ps1: status.json payload missing step/total/detail fields'); + } else { + out('PASS', 'notify-island.ps1: writes step/total/detail to status.json'); + } + } + if (await exists(substepWidgetPath)) { + const widget = await readFile(substepWidgetPath, 'utf8'); + if (!/function Build-DisplayMessage/.test(widget)) { + out('FAIL', 'mcode-island.ps1: Build-DisplayMessage function missing'); + } else { + out('PASS', 'mcode-island.ps1: Build-DisplayMessage function present'); + } + if (!/\[int\]\$Step\s*=\s*-1/.test(widget) || + !/\[int\]\$Total\s*=\s*-1/.test(widget)) { + out('FAIL', 'mcode-island.ps1: Update-State missing Step/Total params'); + } else { + out('PASS', 'mcode-island.ps1: Update-State accepts Step/Total/Detail'); + } + } + const substepTestPath = join(PLUGIN_ROOT, 'scripts', 'test-substep-progress.mjs'); + if (!(await exists(substepTestPath))) { + out('WARN', 'scripts/test-substep-progress.mjs missing (sub-step detailed tests not run)'); + } else { + out('PASS', 'scripts/test-substep-progress.mjs exists (run separately for full suite)'); + } + + // 5c2. Click target resolution (desktop-app adaptation). + // + // The click used to be a plain show/hide toggle of the caller's terminal. + // In the desktop app the agent is hosted by the app and that terminal + // window belongs to the app session, so "hide the terminal" is + // meaningless there -- the agent keeps running behind a static pill. + // What the user wants is "take me back to the app window". + // + // The contract is a priority chain, resolved on every click: + // 1. manual pin (pin.json) -> focus that window + // 2. desktop app main window -> focus it + // 3. the caller's terminal -> keep the old show/hide toggle + // + // These locks are text-shaped (the widget is a WPF app; there is no + // headless way to click it), so every one of them has to anchor on the + // code that actually carries the contract -- and has to be able to fail. + if (await exists(substepWidgetPath)) { + const widget = await readFile(substepWidgetPath, 'utf8'); + + // (a) the resolution helpers exist as named functions + for (const [name, re] of [ + ['Resolve-CallerWindow', /function Resolve-CallerWindow\b/], + ['Toggle-CallerWindow', /function Toggle-CallerWindow\b/], + ['Resolve-ClickTarget', /function Resolve-ClickTarget\b/], + ['Resolve-PinnedWindow', /function Resolve-PinnedWindow\b/], + ['Find-DesktopAppWindow', /function Find-DesktopAppWindow\b/], + ['Invoke-ClickTarget', /function Invoke-ClickTarget\b/], + ['Focus-Window', /function Focus-Window\b/], + ['Toggle-Window', /function Toggle-Window\b/], + ]) { + if (re.test(widget)) { + out('PASS', `mcode-island.ps1: ${name} function present`); + } else { + out('FAIL', `mcode-island.ps1: ${name} function missing (click-target contract broken)`); + } + } + + // (b) the priority chain is in the right ORDER inside + // Resolve-ClickTarget. Order is the whole contract: a pin that loses + // to the desktop app is useless for a CLI user who happens to have + // the app open at the same time. + const clickTargetBody = (widget.match(/function Resolve-ClickTarget[\s\S]*?\n\}\n/) || [''])[0]; + if (!clickTargetBody) { + out('FAIL', 'mcode-island.ps1: Resolve-ClickTarget body not found (priority drift lock skipped)'); + } else { + const pinAt = clickTargetBody.indexOf('Resolve-PinnedWindow'); + const appAt = clickTargetBody.indexOf('Find-DesktopAppWindow'); + const callerAt = clickTargetBody.indexOf('Resolve-CallerWindow'); + if (pinAt < 0 || appAt < 0 || callerAt < 0) { + out('FAIL', `mcode-island.ps1: Resolve-ClickTarget does not consult all three sources (pin=${pinAt} app=${appAt} caller=${callerAt})`); + } else if (!(pinAt < appAt && appAt < callerAt)) { + out('FAIL', `mcode-island.ps1: Resolve-ClickTarget priority order is wrong -- it must be pin -> desktop app -> caller terminal (offsets pin=${pinAt} app=${appAt} caller=${callerAt})`); + } else { + out('PASS', 'mcode-island.ps1: Resolve-ClickTarget resolves pin -> desktop app -> caller terminal in that order'); + } + if (!/Mode\s*=\s*'focus'/.test(clickTargetBody)) { + out('FAIL', "mcode-island.ps1: Resolve-ClickTarget does not mark any target as Mode = 'focus'"); + } else { + out('PASS', "mcode-island.ps1: Resolve-ClickTarget marks the pin / desktop app as Mode = 'focus'"); + } + if (!/Mode\s*=\s*'toggle'/.test(clickTargetBody)) { + out('FAIL', "mcode-island.ps1: Resolve-ClickTarget does not keep Mode = 'toggle' for the caller's terminal"); + } else { + out('PASS', "mcode-island.ps1: Resolve-ClickTarget keeps the caller's terminal on Mode = 'toggle'"); + } + } + + // (c) the click handler goes through the dispatcher, not straight to + // the toggle. A handler that still called Toggle-CallerWindow would + // hide the terminal instead of focusing the app. + const clickMatch = widget.match(/Add_MouseLeftButtonUp\([\s\S]*?\}\s*\)\s*$/m); + if (!clickMatch) { + out('WARN', 'mcode-island.ps1: Add_MouseLeftButtonUp handler not found (drift lock skipped)'); + } else if (!/Invoke-ClickTarget\b/.test(clickMatch[0])) { + out('FAIL', 'mcode-island.ps1: MouseLeftButtonUp does not invoke Invoke-ClickTarget (the click would skip the desktop-app target)'); + } else if (/Toggle-CallerWindow\b/.test(clickMatch[0])) { + out('FAIL', 'mcode-island.ps1: MouseLeftButtonUp still calls Toggle-CallerWindow directly -- it must go through Invoke-ClickTarget'); + } else { + out('PASS', 'mcode-island.ps1: MouseLeftButtonUp invokes Invoke-ClickTarget (pin / desktop app / terminal resolved per click)'); + } + + // (d) the dispatcher honours the resolved Mode. Always focusing would + // break the CLI "click to hide my terminal" muscle memory; always + // toggling would hide the desktop app window. + const dispatchBody = (widget.match(/function Invoke-ClickTarget[\s\S]*?\n\}\n/) || [''])[0]; + if (!dispatchBody) { + out('FAIL', 'mcode-island.ps1: Invoke-ClickTarget body not found (mode dispatch drift lock skipped)'); + } else if (!/Focus-Window\s+\$t\b/.test(dispatchBody)) { + out('FAIL', 'mcode-island.ps1: Invoke-ClickTarget does not call Focus-Window for a focus target'); + } else if (!/Toggle-Window\s+\$t\b/.test(dispatchBody)) { + out('FAIL', 'mcode-island.ps1: Invoke-ClickTarget does not call Toggle-Window for a toggle target'); + } else if (!/\$t\.Mode\s*-eq\s*'focus'/.test(dispatchBody)) { + out('FAIL', "mcode-island.ps1: Invoke-ClickTarget does not branch on the resolved Mode -- a dispatcher that always picks one behaviour breaks the other target kind"); + } else { + out('PASS', 'mcode-island.ps1: Invoke-ClickTarget dispatches focus vs toggle by the resolved Mode'); + } + + // (e) the desktop app is found by process name, and only inside the + // widget's own session. A cross-session match could focus another + // session's app instance. + const appFn = (widget.match(/function Find-DesktopAppWindow[\s\S]*?\n\}\n/) || [''])[0]; + if (!appFn) { + out('FAIL', 'mcode-island.ps1: Find-DesktopAppWindow body not found'); + } else { + if (!/MiniMax Inside Code/.test(widget)) { + out('FAIL', "mcode-island.ps1: the desktop app allowlist does not contain 'MiniMax Inside Code' (the desktop app window would never be found)"); + } else { + out('PASS', "mcode-island.ps1: the desktop app allowlist contains 'MiniMax Inside Code'"); + } + if (!/SessionId\s*-eq\s*\$mySession/.test(appFn)) { + out('FAIL', 'mcode-island.ps1: Find-DesktopAppWindow does not restrict the search to the widget session'); + } else { + out('PASS', 'mcode-island.ps1: Find-DesktopAppWindow only considers processes in the widget session'); + } + } + + // (f) focusing must never hide. Focus-Window serves the desktop app + // and a pinned window; hiding the app would take the agent surface + // away from the user, which is exactly the bug this adaptation fixes. + const focusBody = (widget.match(/function Focus-Window[\s\S]*?\n\}\n/) || [''])[0]; + if (!focusBody) { + out('FAIL', 'mcode-island.ps1: Focus-Window body not found'); + } else if (/ShowWindow\(\s*\$Target\.Hwnd\s*,\s*0\s*\)/.test(focusBody)) { + out('FAIL', 'mcode-island.ps1: Focus-Window calls ShowWindow(SW_HIDE) -- focusing the desktop app must never hide it'); + } else if (!/SetForegroundWindow/.test(focusBody)) { + out('FAIL', 'mcode-island.ps1: Focus-Window does not call SetForegroundWindow'); + } else { + out('PASS', 'mcode-island.ps1: Focus-Window raises and activates without ever hiding the target'); + } + + // (g) the pin has to be sticky. It lives in pin.json, not in the + // caller.json that every status transition overwrites: a pin stored + // in caller.json dies at the next notify, which makes "the pin beats + // the desktop app" unreachable in practice. + const pinBody = (widget.match(/function Resolve-PinnedWindow[\s\S]*?\n\}\n/) || [''])[0]; + if (!pinBody) { + out('FAIL', 'mcode-island.ps1: Resolve-PinnedWindow body not found'); + } else if (!/Join-Path[^\n]*pin\.json/.test(pinBody)) { + // Anchor on the path expression, not on the bare file name: a + // Dbg message that merely mentions pin.json must not satisfy + // this lock. + out('FAIL', 'mcode-island.ps1: Resolve-PinnedWindow does not read pin.json (the pin would be overwritten by every status transition)'); + } else { + out('PASS', 'mcode-island.ps1: Resolve-PinnedWindow reads the sticky pin.json'); + } + + // (h) the writer has to write the file the resolver reads, and it + // must not write caller.json -- that file is overwritten by every + // status transition, so a pin stored there is gone at the next + // notify. + const pinWriterPath = join(PLUGIN_ROOT, 'pin-island.ps1'); + if (!(await exists(pinWriterPath))) { + out('FAIL', 'pin-island.ps1 missing (pin writer drift lock skipped)'); + } else { + const pinWriter = await readFile(pinWriterPath, 'utf8'); + // Anchor on the destination expression, not on the bare file + // name: the file explains in a comment why it does NOT use + // caller.json, and that comment must not satisfy this lock. + if (!/Join-Path[^\n]*'pin\.json'/.test(pinWriter)) { + out('FAIL', 'pin-island.ps1 does not write pin.json (the pin would not survive the next status transition)'); + } else if (/Join-Path[^\n]*'caller\.json'/.test(pinWriter)) { + out('FAIL', 'pin-island.ps1 still writes caller.json (the pin would be overwritten by every status transition)'); + } else { + out('PASS', 'pin-island.ps1 writes the sticky pin.json'); + } + } + + // (i) unpin has to clear the sticky pin, otherwise the pin survives + // the one command that exists to remove it. + const cmdPath = join(PLUGIN_ROOT, 'mcode-island.cmd'); + if (!(await exists(cmdPath))) { + out('FAIL', 'mcode-island.cmd missing (unpin drift lock skipped)'); + } else { + const cmd = await readFile(cmdPath, 'utf8'); + // The label has to start the line: the dispatch table also + // mentions "unpin" ("if /i "%1"=="unpin" goto :unpin"). + const unpinBlock = (cmd.match(/\r?\n:unpin[\s\S]*?exit \/b %errorlevel%/i) || [''])[0]; + if (!unpinBlock) { + out('FAIL', 'mcode-island.cmd: the unpin command is missing'); + } else if (!/Remove-Item[^\n]*'mcode-island\\pin\.json'/.test(unpinBlock)) { + // Anchor on the removal itself: the command's own output text + // mentions "pin.json" and must not satisfy this lock. + out('FAIL', 'mcode-island.cmd: unpin does not clear pin.json (the pinned window would stay the click target)'); + } else { + out('PASS', 'mcode-island.cmd: unpin clears the sticky pin.json'); + } + } + + // (j) the CLI install and the Marketplace package resolve the click + // target differently, and the source says which one it is. The + // Marketplace builder flips this ONE line to 'app' and fails the + // build when it cannot find it, so a renamed switch cannot silently + // ship the CLI behaviour to the desktop users. + if (/\$script:clickPrefers\s*=\s*'terminal'/.test(widget)) { + out('PASS', 'mcode-island.ps1: the CLI install prefers the caller terminal'); + } else { + out('FAIL', "mcode-island.ps1: the CLI install does not declare $script:clickPrefers = 'terminal' -- the Marketplace builder flips that exact line"); + } + if (/\$script:clickPrefers\s+-eq\s+'app'/.test(widget)) { + out('PASS', 'mcode-island.ps1: Resolve-ClickTarget branches on the preference'); + } else { + out('FAIL', 'mcode-island.ps1: Resolve-ClickTarget does not branch on $script:clickPrefers, so the two builds cannot differ'); + } + + // (k) A session row has to reach the conversation it names. + // + // 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. Without that, a row click on + // the desktop could only fall through to "open a new terminal", + // which is the one thing the session list promised never to do. + if (/function Switch-ToAppConversation\b/.test(widget)) { + out('PASS', 'mcode-island.ps1: Switch-ToAppConversation exists (a session row can reach the app conversation)'); + } else { + out('FAIL', 'mcode-island.ps1: no Switch-ToAppConversation -- a session row cannot reach its conversation in the desktop app'); + } + const sessionClickBody = (widget.match(/function Invoke-SessionClick[\s\S]*?\n\}\n/) || [''])[0]; + if (!sessionClickBody) { + out('FAIL', 'mcode-island.ps1: Invoke-SessionClick body not found (session-row drift lock skipped)'); + } else if (!/Switch-ToAppConversation\s+\$names/.test(sessionClickBody)) { + out('FAIL', 'mcode-island.ps1: Invoke-SessionClick does not try the app conversation'); + } else { + out('PASS', 'mcode-island.ps1: Invoke-SessionClick tries the app conversation'); + // The "open a new terminal" fallback is gone and must stay gone. + // + // It built `powershell -Command "mcode --session "` out of a + // value that came from a session directory's base64 and from + // sessions.json -- neither is trusted, so an id shaped like + // `x; Set-Content ...` was executable PowerShell. It also + // contradicted the feature: clicking a row means "go back to that + // session", never "start a second one". + // + // Two shapes of lock: the function is gone from the file, and the + // click handler spawns nothing. The handler's comments describe + // the old path, so the code check drops comment lines first -- + // otherwise a comment could satisfy or break the lock by itself. + if (/function\s+Start-SessionTerminal\b/.test(widget)) { + out('FAIL', 'mcode-island.ps1: Start-SessionTerminal is back -- it turns a session id into PowerShell source'); + } else if (/--session\s+\$/.test(widget)) { + out('FAIL', 'mcode-island.ps1: something interpolates a session id into a --session command line'); + } else { + out('PASS', 'mcode-island.ps1: no code path turns a session id into a command line'); + } + const clickCode = sessionClickBody.split('\n').filter((line) => !/^\s*#/.test(line)).join('\n'); + if (/Start-Process|Start-SessionTerminal/.test(clickCode)) { + out('FAIL', 'mcode-island.ps1: Invoke-SessionClick still spawns something -- a row click must fail closed'); + } else if (!/\$id\s+-notmatch\s+'/.test(clickCode)) { + out('FAIL', 'mcode-island.ps1: Invoke-SessionClick does not validate the session id before using it'); + } else { + out('PASS', 'mcode-island.ps1: Invoke-SessionClick validates the id and spawns nothing'); + } + } + // The UIA lookup has to filter by name. Reading every element of the + // app's tree costs ~500ms, and the click handler runs on the UI + // thread -- that would freeze the pill. + const lookupBody = (widget.match(/function Get-AppConversationButton[\s\S]*?\n\}\n/) || [''])[0]; + if (!lookupBody) { + out('FAIL', 'mcode-island.ps1: Get-AppConversationButton body not found'); + } else if (!/NameProperty/.test(lookupBody)) { + out('FAIL', 'mcode-island.ps1: the app conversation lookup does not filter by name -- scanning the whole tree blocks the UI thread'); + } else { + out('PASS', 'mcode-island.ps1: the app conversation lookup filters by name (no full-tree scan)'); + } + + // (k2) The list has a height budget, not just a data budget. + // + // The detector caps the data side ($script:MAX_SESSIONS), but 20 rows + // at ~48px is taller than the work area on most screens: the bottom + // of the list runs off the screen and still covers the windows + // underneath. Three locks: a sane row cap, the overflow notice + // (silent truncation would claim nothing else is running -- the exact + // question this list exists to answer), and the work-area clamp. + const listBody = (widget.match(/function Show-SessionList[\s\S]*?\n\}\n/) || [''])[0]; + const rowCap = widget.match(/\$script:MAX_SESSION_ROWS\s*=\s*(\d+)/); + if (!listBody) { + out('FAIL', 'mcode-island.ps1: Show-SessionList body not found (height-cap drift lock skipped)'); + } else if (!rowCap) { + out('FAIL', 'mcode-island.ps1: no $script:MAX_SESSION_ROWS -- the session list height is unbounded'); + } else if (+rowCap[1] < 6 || +rowCap[1] > 20) { + out('FAIL', `mcode-island.ps1: $script:MAX_SESSION_ROWS = ${rowCap[1]} is outside 6..20 -- too small hides work, too large runs off the screen`); + } else if (!/Select-Object -First \$script:MAX_SESSION_ROWS/.test(listBody)) { + out('FAIL', 'mcode-island.ps1: Show-SessionList does not slice the rows by $script:MAX_SESSION_ROWS'); + } else if (!/if\s*\(\s*\$hidden\s*-gt\s*0\s*\)\s*\{\s*Add-SessionOverflowRow\s+\$hidden\s*\}/.test(listBody)) { + out('FAIL', 'mcode-island.ps1: rows beyond the cap vanish without the "还有 N 条" notice (or the notice is not gated on $hidden)'); + } else { + out('PASS', `mcode-island.ps1: the list is capped at ${rowCap[1]} rows and says how many are hidden`); + } + if (!listBody) { + // already reported above + } else if (!/GetWorkAreaForWindow/.test(listBody) || !/\$window\.Height\s*=/.test(listBody)) { + out('FAIL', 'mcode-island.ps1: the list height is not clamped to the work area -- it can end up taller than the screen'); + } else { + out('PASS', 'mcode-island.ps1: the list height is clamped to the work area'); + } + + // (l) The click must be scoped to the sidebar's conversation list. + // + // "Any button in the window with that name" is not a conversation + // click: the same name can be a heading, a label or a button in + // another panel. The list container is found first -- the one + // element that holds SEVERAL known session titles -- and the target + // is looked up only inside it. + if (/function Get-AppConversationList\b/.test(widget)) { + out('PASS', 'mcode-island.ps1: the app conversation lookup resolves the sidebar list first'); + } else { + out('FAIL', 'mcode-island.ps1: no Get-AppConversationList -- the click would look for a button by name across the whole window'); + } + if (!/Get-AppConversationList\s+\$hwnd/.test(lookupBody)) { + out('FAIL', 'mcode-island.ps1: the button lookup does not go through the conversation list'); + } else if (/AutomationElement\]::FromHandle/.test(lookupBody)) { + out('FAIL', 'mcode-island.ps1: the button lookup still starts from the window root instead of the conversation list'); + } else { + out('PASS', 'mcode-island.ps1: the button lookup is scoped to the conversation list'); + } + + // Round-15: Toggle's restore branch must call SW_MAXIMIZE (3), not + // SW_SHOW (5) / SW_RESTORE (9). SW_HIDE preserves the window's + // "non-maximized size"; if the WT window got accidentally resized + // to a thin strip (e.g., 480x84 from a snap gesture or our own + // mouse_event test artifacts), SW_SHOW / SW_RESTORE would re-show + // it as that strip — the user's complaint was "hide works, show is + // a thin strip". SW_MAXIMIZE forces full-screen on hidden / + // minimized / normal windows alike; no-op on already-maximized. + const toggleMatch = widget.match(/function Toggle-Window[\s\S]*?\n\}\n/); + if (!toggleMatch) { + out('WARN', 'mcode-island.ps1: Toggle-Window body not found (drift lock skipped)'); + } else { + const toggleBody = toggleMatch[0]; + // Round-15 (rewritten): "show the window full-screen" now lives + // in ONE helper, and every caller must use it. + // + // SW_MAXIMIZE (3), not SW_SHOW (5) / SW_RESTORE (9): SW_HIDE + // preserves the window's "non-maximized size", and if that + // size got clobbered -- a SetWindowPos without SWP_NOSIZE + // resizes WT to 0x0 and WT clamps it to a ~480x84 strip -- + // then SW_SHOW / SW_RESTORE re-shows the strip instead of the + // window. That was the user's "show is a thin strip" bug. + // + // The session-list click needs the same guarantee (it raises + // the terminal without toggling it), so the helper is shared + // and the lock has to check the helper AND both callers + // instead of the text inside Toggle-Window alone. + const fullScreenMatch = widget.match(/function Show-WindowFullScreen[\s\S]*?\n\}\n/); + if (!fullScreenMatch) { + out('FAIL', 'mcode-island.ps1: Show-WindowFullScreen helper not found -- the restore path and the session click both need one implementation that maximizes to the work area'); + } else { + const fsBody = fullScreenMatch[0]; + const maximizeAt = fsBody.search(/ShowWindow\(\s*\$h\s*,\s*3\s*\)/); + const restoreAt = fsBody.search(/ShowWindow\(\s*\$h\s*,\s*9\s*\)/); + const showAt = fsBody.search(/ShowWindow\(\s*\$h\s*,\s*5\s*\)/); + if (maximizeAt < 0) { + out('FAIL', 'mcode-island.ps1: Show-WindowFullScreen does not call SW_MAXIMIZE (ShowWindow(_, 3)). A regression to SW_SHOW (5) or SW_RESTORE (9) re-shows the window at its pre-hide size (e.g., 480x84 strip if WT got accidentally resized).'); + } else if (showAt >= 0) { + out('FAIL', 'mcode-island.ps1: Show-WindowFullScreen calls SW_SHOW (5) -- SW_SHOW re-shows the window at its pre-hide size and defeats the maximize intent.'); + } else if (restoreAt < 0) { + // SW_RESTORE has to come FIRST: the SetWindowPos that + // writes the restored size only takes effect while the + // window is in its normal (not maximized) state. + out('FAIL', 'mcode-island.ps1: Show-WindowFullScreen does not call SW_RESTORE (9) before the maximize -- without it the SetWindowPos cannot write the restored size, so a stale size (e.g. a 480x84 strip) survives the next restore.'); + } else if (restoreAt > maximizeAt) { + out('FAIL', 'mcode-island.ps1: Show-WindowFullScreen calls SW_RESTORE (9) AFTER SW_MAXIMIZE (3) -- restoring after the maximize puts the window back at its pre-hide size (the 480x84 strip bug).'); + } else if (!/Show-WindowFullScreen/.test(toggleBody)) { + out('FAIL', 'mcode-island.ps1: Toggle-Window does not call Show-WindowFullScreen -- the show path would skip the maximize and go back to the pre-hide size.'); + } else { + const focusTermMatch = widget.match(/function Focus-TerminalWindow[\s\S]*?\n\}\n/); + const focusTermBody = focusTermMatch ? focusTermMatch[0] : ''; + if (!focusTermBody) { + out('FAIL', 'mcode-island.ps1: Focus-TerminalWindow body not found -- the session click must raise the terminal full-screen too'); + } else if (!/Show-WindowFullScreen/.test(focusTermBody)) { + out('FAIL', 'mcode-island.ps1: Focus-TerminalWindow does not call Show-WindowFullScreen -- raising the terminal from the session list would restore whatever "normal size" the window last had (e.g. a 480x84 strip in the top-left corner)'); + } else { + out('PASS', 'mcode-island.ps1: the restore path and the session click both force SW_MAXIMIZE via Show-WindowFullScreen (full-screen on show, fixes the 480x84 strip)'); + } + } + } + + // Round-16 (rewritten): the work-area size lives in the same + // helper now, so the lock follows it there. + // + // SW_MAXIMIZE alone is insufficient on multi-monitor + + // DPI-virtualized setups: the user's primary monitor is + // physically 2560x1440, but WinForms [Screen]::PrimaryScreen + // reports 1920x1080 (DPI virtualization). SW_MAXIMIZE follows + // the 1920x1080 number and leaves WT at ~75% of the physical + // screen -- visually "in the top-left corner" of the user's 2K + // monitor. The drift lock forces the explicit SetWindowPos + // path. + const fullScreenBody = fullScreenMatch ? fullScreenMatch[0] : ''; + if (!/GetWorkAreaForWindow|GetMonitorInfo|MonitorFromWindow/.test(fullScreenBody)) { + out('FAIL', 'mcode-island.ps1: Show-WindowFullScreen does not query the monitor work area. Without MonitorFromWindow + SetWindowPos(explicit size), SW_MAXIMIZE alone fills only the WinForms 1920x1080 logical work area, not the actual 2560x1440 physical monitor -- leaves WT at the top-left 75%.'); + } else if (!/SetWindowPos\([^)]*\$wa\.|SetWindowPos\(\$h,[^,]+,\s*\$wa\.Left,\s*\$wa\.Top,\s*\$cx,\s*\$cy/.test(fullScreenBody)) { + out('FAIL', 'mcode-island.ps1: Show-WindowFullScreen has the monitor query but does not SetWindowPos with work-area coords. The contract is: read monitor work area, then SetWindowPos with explicit (Left, Top, cx, cy) -- never rely on SW_MAXIMIZE alone for size.'); + } else { + out('PASS', 'mcode-island.ps1: the full-screen path forces work-area size via MonitorFromWindow + SetWindowPos (fills 2560x1440 physical monitor, not just 1920x1080 logical)'); + } + + // Round-17 + round-19: the follow-up z-order SetWindowPos call + // (HWND_TOP, to push WT forward without foreground permission) + // must carry both SWP_NOSIZE and SWP_NOMOVE, and must NOT carry + // SWP_NOZORDER. + // + // SWP_NOSIZE cx=0/cy=0 is otherwise "resize to 0x0", triggering + // WT's min-size fallback to a 480x76 strip — the + // exact regression the user saw in round-17. + // SWP_NOMOVE X=0/Y=0 is otherwise "move to (0,0)". Invisible on + // a single primary monitor, but any secondary monitor + // whose origin is not 0 gets the restored window + // yanked to the primary's top-left corner + // (round-19 review #3). + // no SWP_NOZORDER that flag makes Windows ignore + // hWndInsertAfter entirely, so passing HWND_TOP + // alongside it is self-defeating: the whole point of + // this call is the z-order change. + // + // The round-17 version of this lock asserted the literal + // `SWP_NOZORDER -bor SWP_NOSIZE`, which is exactly the pair the + // review asked to change, so it had to be rewritten rather than + // updated. Match on the flag names, not their order. + if (!/SWP_NOSIZE\s*=\s*0x0001/.test(widget)) { + out('FAIL', 'mcode-island.ps1: WinAPI class missing SWP_NOSIZE constant (0x0001).'); + } else if (!/SWP_NOMOVE\s*=\s*0x0002/.test(widget)) { + out('FAIL', 'mcode-island.ps1: WinAPI class missing SWP_NOMOVE constant (0x0002).'); + } else { + // Pull the flags expression that feeds the HWND_TOP call. It + // is assigned just ABOVE the call, not inside it, so anchor on + // the assignment and look for the HWND_TOP call within the + // same statement rather than scanning forward from the call. + const flagsExpr = (toggleBody.match(/\$nofollow\s*=\s*([^\n\r]+)/) || [null, ''])[1]; + const hasZorderCall = /SetWindowPos\([^)]*HWND_TOP/.test(toggleBody); + + const problems = []; + if (!hasZorderCall) { + problems.push('the HWND_TOP SetWindowPos call is gone, so the window is never pushed forward'); + } + if (!/SWP_NOSIZE/.test(flagsExpr)) { + problems.push('SWP_NOSIZE (cx=0/cy=0 would resize WT to 0x0 and trigger its 480x76 min-size fallback)'); + } + if (!/SWP_NOMOVE/.test(flagsExpr)) { + problems.push('SWP_NOMOVE (X=0/Y=0 would move the window to (0,0) on any monitor whose origin is not 0)'); + } + if (/SWP_NOZORDER/.test(flagsExpr)) { + problems.push('SWP_NOZORDER is present, which makes Windows ignore hWndInsertAfter and defeats the HWND_TOP z-order call'); + } + + if (problems.length > 0) { + for (const p of problems) { + out('FAIL', `mcode-island.ps1: Toggle z-order SetWindowPos(HWND_TOP) — ${p}`); + } + } else { + out('PASS', 'mcode-island.ps1: Toggle z-order SetWindowPos carries SWP_NOSIZE + SWP_NOMOVE and omits SWP_NOZORDER (size preserved, position preserved, z-order actually applied)'); + } + } + } + } + + // 5d. Drift lock: the round-18 tool-verb table. The pill shows a + // present-tense verb ("Running") while a tool is in flight and a + // past-tense one ("Ran") once it returns, mirroring the mcode CLI TUI + // descriptor table (launcher-GHPADSKI.js HL[]). Before this the pill + // showed the bare tool name for every phase, so "working" and "done" + // were indistinguishable at a glance. + // + // These locks are deliberately text-shaped rather than behavioral: a + // behavioral test would need a real mcode session log. What we can + // cheaply guarantee is that (a) the table exists, (b) it covers the + // tools the detector actually infers, and (c) all three Infer-State + // branches route through the verb lookup instead of hardcoding names. + const detectPath = join(PLUGIN_ROOT, 'mcode-status-detect.ps1'); + if (!(await exists(detectPath))) { + out('FAIL', 'mcode-status-detect.ps1 missing (tool-verb drift lock skipped)'); + } else { + const detect = await readFile(detectPath, 'utf8'); + + // (a) table + helpers present + for (const [label, re] of [ + ['$TOOL_ACTIONS table', /\$TOOL_ACTIONS\s*=\s*@\{/], + ['$TOOL_FAMILIES table', /\$TOOL_FAMILIES\s*=\s*@\{/], + ['Get-ToolKey helper', /function Get-ToolKey\s*\(/], + ['Get-ToolVerb helper', /function Get-ToolVerb\s*\(/], + ['Get-ToolVerbFallback helper', /function Get-ToolVerbFallback\s*\(/], + ['Get-ToolFamily helper', /function Get-ToolFamily\s*\(/], + ]) { + if (re.test(detect)) { + out('PASS', `mcode-status-detect.ps1: ${label} present`); + } else { + out('FAIL', `mcode-status-detect.ps1: ${label} missing (round-18 tool-verb contract broken)`); + } + } + + // (b) the table covers the tools users actually see. Each entry must + // carry all three phases; a partial entry would render an empty + // message and the pill would silently go blank for that tool. + const actionsMatch = detect.match(/\$TOOL_ACTIONS\s*=\s*@\{([\s\S]*?)\n\}/); + if (!actionsMatch) { + out('FAIL', 'mcode-status-detect.ps1: cannot slice $TOOL_ACTIONS body'); + } else { + const body = actionsMatch[1]; + const entryRe = /'([a-z0-9_]+)'\s*=\s*@\{\s*running\s*=\s*'([^']*)'\s*;\s*done\s*=\s*'([^']*)'\s*;\s*fail\s*=\s*'([^']*)'\s*\}/g; + const found = new Map(); + let m; + while ((m = entryRe.exec(body)) !== null) { + found.set(m[1], { running: m[2], done: m[3], fail: m[4] }); + } + out('PASS', `mcode-status-detect.ps1: $TOOL_ACTIONS has ${found.size} entries with all 3 phases`); + + for (const required of ['bash', 'read', 'write', 'edit', 'grep', 'glob', 'task']) { + if (found.has(required)) { + out('PASS', `$TOOL_ACTIONS covers "${required}"`); + } else { + out('FAIL', `$TOOL_ACTIONS is missing "${required}" (the pill would show a bare name for it)`); + } + } + // Every mapped family must resolve to a family, otherwise the + // widget's $familyMap lookup silently falls back to the state + // color and the round-18 tinting never happens. + const famMatch = detect.match(/\$TOOL_FAMILIES\s*=\s*@\{([\s\S]*?)\n\}/); + if (!famMatch) { + out('FAIL', 'mcode-status-detect.ps1: cannot slice $TOOL_FAMILIES body'); + } else { + for (const [key, fam] of Object.entries({ + bash: 'shell', read: 'read', edit: 'write', write: 'write', + grep: 'search', glob: 'search', task: 'task', + task_output: 'task', ask_user: 'task', + web_search: 'web', web_fetch: 'web', todowrite: 'plan', + })) { + // ask_user is intentionally excluded from this list. It was + // mapped to the 'task' family in round-18 and is deliberately + // unmapped in 5d2, where it reports `waiting` instead. + if (key === 'ask_user') continue; + const re = new RegExp(`'${key}'\\s*=\\s*'${fam}'`); + if (re.test(famMatch[1])) { + out('PASS', `$TOOL_FAMILIES maps "${key}" -> "${fam}"`); + } else { + out('FAIL', `$TOOL_FAMILIES does not map "${key}" -> "${fam}" (widget tinting will not fire for it)`); + } + } + } + } + + // (c) all three Infer-State branches must route through the verb + // lookup. This is the actual regression: reverting any one branch to + // `"$($m.toolName) $MSG_OK"` would still pass a "table exists" check + // while the pill went back to showing a bare name. + // + // There are TWO sites per state (the ledger.jsonl branch and the + // messages.jsonl branch), so a presence test is not enough -- breaking + // only the ledger branch leaves the messages branch matching and the + // check stays green. These locks assert a minimum occurrence count so + // that reverting EITHER site goes red. + for (const [label, re, min] of [ + ['WORKING branch uses Get-ToolVerb', /state=\$S_WORKING;\s*message="\$verb\s/g, 2], + ['DONE branch uses Get-ToolVerb', /state=\$S_DONE;\s*message=\$verb/g, 2], + ['ERROR branch uses Get-ToolVerb', /state=\$S_ERROR;\s*message=\$verb/g, 2], + ]) { + const hits = detect.match(re); + const n = hits ? hits.length : 0; + if (n >= min) { + out('PASS', `mcode-status-detect.ps1: ${label} (${n}/${min} sites)`); + } else { + out('FAIL', `mcode-status-detect.ps1: ${label} -- only ${n}/${min} sites use the verb lookup; a branch was reverted to a hardcoded tool name`); + } + } + + // (d) family must be threaded to disk, or the widget can never tint. + if (/family\s*=\s*\$famField/.test(detect)) { + out('PASS', 'mcode-status-detect.ps1: Write-Status emits the family field'); + } else { + out('FAIL', 'mcode-status-detect.ps1: Write-Status does not emit `family` (widget tinting is dead code)'); + } + + // 5d2. Drift lock: ask_user must report `waiting`, not `working`. + // + // ask_user is the only tool whose "in flight" state means the agent is + // BLOCKED on the user rather than busy. Showing it as `working` tells + // the user "leave it alone, it is making progress", which is the + // opposite of the truth and defeats the entire point of the state. + // This matters most on `full access` setups, where the permission hook + // never fires and ask_user is the only thing that ever blocks. + for (const [label, re] of [ + ['$S_WAITING is defined', /\$S_WAITING\s*=\s*(?:_s\s*\(|')/], + ['Test-IsAskUser helper', /function Test-IsAskUser\s*\(/], + ['Get-AskQuestionCount helper', /function Get-AskQuestionCount\s*\(/], + ]) { + if (re.test(detect)) { + out('PASS', `mcode-status-detect.ps1: ${label} present`); + } else { + out('FAIL', `mcode-status-detect.ps1: ${label} missing (ask_user cannot report waiting)`); + } + } + + // Both tool paths (ledger.jsonl and messages.jsonl) must route + // ask_user to waiting. A presence test is not enough here for the same + // reason as the verb locks above: there are two sites, and breaking one + // leaves the other matching. + const askSites = detect.match(/state=\$S_WAITING;\s*message="Asking \$n question/g) || []; + if (askSites.length >= 2) { + out('PASS', `mcode-status-detect.ps1: both ask_user tool paths report waiting (${askSites.length}/2 sites)`); + } else { + out('FAIL', `mcode-status-detect.ps1: only ${askSites.length}/2 ask_user paths report waiting; a path still treats a blocked agent as busy`); + } + + // $args is a PowerShell automatic variable. Naming a function parameter + // $args shadows it, and every read inside the function returns the + // function's own argument list instead of the caller's value -- which + // here silently produced a question count of 0 for every input. Lock + // the parameter name so the regression cannot come back unnoticed. + if (/function Get-AskQuestionCount\(\$toolArgs\)/.test(detect)) { + out('PASS', 'mcode-status-detect.ps1: Get-AskQuestionCount avoids the $args automatic variable'); + } else { + out('FAIL', 'mcode-status-detect.ps1: Get-AskQuestionCount takes $args, which shadows the PowerShell automatic variable and always yields a count of 0'); + } + + // waiting must be in BOTH settle sets. The 60s no-activity fallback + // would otherwise downgrade a pending questionnaire to "已静默 60s" + // exactly when the user has been away longest, and the takeover + // arbitration would let a hook-pushed `working` win forever. + for (const [label, re] of [ + ['60s idle fallback exempts waiting', /\$isSettled\s*=.*-or\s*\(\$curState\s+-eq\s+\$S_WAITING\)/], + ['takeover arbitration treats waiting as settle', /\$isSettleNew\s*=.*-or\s*\(\$newState\s+-eq\s+\$S_WAITING\)/], + ]) { + if (re.test(detect)) { + out('PASS', `mcode-status-detect.ps1: ${label}`); + } else { + out('FAIL', `mcode-status-detect.ps1: ${label} (a pending ask_user would be downgraded or shadowed)`); + } + } + + // ask_user must NOT carry a family tint: it wears the waiting state + // color, and painting it the task cyan would look like an in-flight + // delegation -- the confusion this change exists to remove. + if (!/'ask_user'\s*=\s*'task'/.test(detect)) { + out('PASS', 'mcode-status-detect.ps1: ask_user carries no family tint (waits in the state color)'); + } else { + out('FAIL', "mcode-status-detect.ps1: ask_user is still mapped to the 'task' family, so a blocked agent wears the delegation color"); + } + + // (h) The session-log reader must survive a fat last line. + // + // One session-log line can be hundreds of KB (a screenshot read + // writes the base64 image into a single line). A reader that looks at + // a fixed 64 KB tail window sees nothing but a fragment of that + // line, fails to parse it, and answers with the previously cached + // inference -- which belongs to an EARLIER session. The pill then + // shows the previous task's state for the whole new conversation. + // This is the reported bug; the behavioral proof lives in + // test-substep-progress.mjs, these are the cheap guards. + if (/function Get-LastJsonLine\b/.test(detect)) { + out('PASS', 'mcode-status-detect.ps1: the session-log reader has a dedicated tail-line reader'); + } else { + out('FAIL', 'mcode-status-detect.ps1: no Get-LastJsonLine -- the reader is still the fixed 64 KB window that drops a fat last line'); + } + if (/\$window\s*\*\s*2/.test(detect)) { + out('PASS', 'mcode-status-detect.ps1: the tail window grows until a line parses'); + } else { + out('FAIL', 'mcode-status-detect.ps1: the tail window never grows -- a last line bigger than the window can never be read'); + } + // The cache must be keyed on the path as well as the mtime: a + // session switch must never answer with the previous file's message. + if (/\$script:lastMsgsSnapshot\.path\s*-eq\s*\$file/.test(detect)) { + out('PASS', 'mcode-status-detect.ps1: the message cache is keyed on path AND mtime'); + } else { + out('FAIL', 'mcode-status-detect.ps1: the message cache is keyed on the mtime alone -- a session switch can return the previous file\'s message'); + } + // A read failure must answer "no information", never another + // session's message. + const readLastBody = (detect.match(/function Read-LastMessage[\s\S]*?\n\}\n/) || [''])[0]; + if (!readLastBody) { + out('FAIL', 'mcode-status-detect.ps1: Read-LastMessage body not found (fat-line drift lock skipped)'); + } else { + const catchBlock = (readLastBody.match(/catch\s*\{[\s\S]*?\n\s{2,}\}/) || [''])[0]; + if (/lastInferred/.test(catchBlock)) { + out('FAIL', 'mcode-status-detect.ps1: a read failure answers with the cached inference -- that inference belongs to an earlier file, so the pill shows the previous conversation'); + } else { + out('PASS', "mcode-status-detect.ps1: a read failure never answers with another session's message"); + } + } + } + + // 5e. Drift lock: the widget must consume `family` and apply it ONLY to + // active states. Tinting `done`/`error` by family would destroy the + // green=success / red=failure signal the user relies on. + const widgetPath = join(PLUGIN_ROOT, 'mcode-island.ps1'); + if (!(await exists(widgetPath))) { + out('FAIL', 'mcode-island.ps1 missing (family-tint drift lock skipped)'); + } else { + const widget = await readFile(widgetPath, 'utf8'); + // Hoisted out of the if/else below: the hue-separation check further + // down needs the same slice, and a block-scoped const would be out of + // scope by then. + const famBlock = widget.match(/\$familyMap\s*=\s*@\{([\s\S]*?)\n\}/); + if (!famBlock) { + out('FAIL', 'mcode-island.ps1: $familyMap table missing'); + } else { + for (const fam of ['shell', 'read', 'write', 'search', 'task', 'web', 'plan']) { + if (new RegExp(`^\\s*${fam}\\s*=`, 'm').test(famBlock[1])) { + out('PASS', `$familyMap covers "${fam}"`); + } else { + out('FAIL', `$familyMap does not cover "${fam}"`); + } + } + } + if (/\[string\]\$Family\s*=\s*''/.test(widget)) { + out('PASS', 'mcode-island.ps1: Update-State takes a $Family parameter'); + } else { + out('FAIL', 'mcode-island.ps1: Update-State has no $Family parameter'); + } + // The gate must be checked on the *family tint* line specifically. + // `$State -in @('thinking','working','waiting')` also appears on the + // pulse / elapsed / progress branches, so a bare substring test stays + // green after the tint is ungated -- a false green this section exists + // to prevent. Anchor on the `$Family -and $State` conjunction instead. + if (/\$Family\s+-and\s+\$State\s+-in\s+@\('thinking','working','waiting'\)/.test(widget)) { + out('PASS', 'mcode-island.ps1: family tint is gated to active states (done/error keep their result color)'); + } else { + out('FAIL', 'mcode-island.ps1: family tint is not gated to active states (done/error would lose their green/red result signal)'); + } + if (/\$script:statusDot\.Fill\s*=\s*C\s+\$dotHex/.test(widget) + && /\$script:pulseRing\.Fill\s*=\s*C\s+\$ringHex/.test(widget)) { + out('PASS', 'mcode-island.ps1: dot/ring are painted from the resolved color (family-aware)'); + } else { + out('FAIL', 'mcode-island.ps1: dot/ring still read $s.dot directly, bypassing family tinting'); + } + + // Perceptual separation. Two families landing on near-identical colors + // are indistinguishable on the pill, which defeats the point of + // tinting. Measured with CIE76 dE in CIELAB, NOT raw luminance: + // luminance alone calls blue and purple "identical" (0.02 apart) even + // though they are plainly different hues, so a luminance threshold + // just produces false alarms. dE < 20 is the usual "not the same color + // to a human eye" cutoff for flat UI fills. + const famColors = new Map(); + // No `^` anchor: with the `m` flag a leading `\s*` is free to swallow + // the preceding newline and match mid-line, and with a greedy + // [\s\S]* body it can also skip the first entry. A `g`-only scan with + // a `[ \t]*` (not `\s*`) indent keeps one match per table row. + // Colors in the table are 8-digit #AARRGGBB (the alpha byte is FF), + // so the pattern has to be {8} or the closing quote never lines up. + const colorRe = /[ \t]*([a-z]+)[ \t]*=[ \t]*'(#[0-9A-Fa-f]{8})'/g; + let cm; + while ((cm = colorRe.exec(famBlock[1])) !== null) { + famColors.set(cm[1], cm[2].slice(3).toUpperCase()); // drop #FF alpha + } + if (famColors.size < 7) { + out('FAIL', `$familyMap: parsed only ${famColors.size}/7 family colors; the separation check below would be vacuous`); + } else { + out('PASS', `$familyMap: parsed all ${famColors.size} family colors`); + } + // sRGB -> XYZ (D65) -> CIELAB + const toLab = (hex) => { + const ch = [0, 2, 4].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255); + const lin = ch.map((v) => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4)); + const [r, g, b] = lin; + const X = (0.4124564 * r + 0.3575761 * g + 0.1804375 * b) / 0.95047; + const Y = (0.2126729 * r + 0.7151522 * g + 0.0721750 * b); + const Z = (0.0193339 * r + 0.1191920 * g + 0.9503041 * b) / 1.08883; + const f = (t) => (t > 0.008856 ? Math.cbrt(t) : 7.787 * t + 16 / 116); + const [fx, fy, fz] = [f(X), f(Y), f(Z)]; + return [116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)]; + }; + const deltaE = (a, b) => { + const [la, aa, ba] = toLab(a); + const [lb, ab, bb] = toLab(b); + return Math.hypot(la - lb, aa - ab, ba - bb); + }; + const MIN_DE = 20; + const keys = [...famColors.keys()]; + let tooClose = 0; + for (let i = 0; i < keys.length; i++) { + for (let j = i + 1; j < keys.length; j++) { + const d = deltaE(famColors.get(keys[i]), famColors.get(keys[j])); + if (d < MIN_DE) { + out('FAIL', `$familyMap colors "${keys[i]}" (#${famColors.get(keys[i])}) and "${keys[j]}" (#${famColors.get(keys[j])}) are only dE ${d.toFixed(1)} apart (< ${MIN_DE}); they read as the same color on the pill`); + tooClose++; + } + } + } + if (tooClose === 0) { + out('PASS', `$familyMap: all ${keys.length} family colors are perceptually distinct (min pairwise CIELAB dE >= ${MIN_DE})`); + } + } + // 5b. Drift lock: permission-request.ps1 must emit `{"decision":"ask"}`, // not `allow` or `deny`. The 0.2.4 Runtime default for PermissionRequest // is fail-closed; an observer Hook that returns `allow` or `deny` @@ -442,6 +1275,693 @@ const main = async () => { if (bad === 0) out('PASS', 'Get-5hUsage.ps1: no hardcoded host paths'); } + // 7. 5h endpoint disclosure consistency (round-20). + // + // Contract: every MiniMax API host the plugin NAMES in user-facing + // docs (and in the lib's own trailing comment) must be the SAME host + // the code actually calls. The endpoint used to be obfuscated into a + // byte array, so this check DECODED that array and compared the docs + // against the decoded truth. The endpoint is plain text now -- the + // byte array only hid it from a grep, which is indistinguishable from + // hiding it from review -- so the check reads the literal. It still + // never hard-codes the endpoint itself, which matters twice over: the + // check cannot drift into becoming a second copy of the disclosure it + // is meant to police, and it cannot be satisfied by "fixing" the docs + // to match whatever the code happens to be tomorrow. + // + // This exists because the two drifted. The README carried an absolute + // security claim ("no request is ever sent to a host other than + // api.minimax.io") that was simply false -- the code never called + // that host. A disclosure that overstates isolation is worse than no + // disclosure at all. + const planLibAbs = join(PLUGIN_ROOT, 'scripts', 'lib', 'Get-5hUsage.ps1'); + if (await exists(planLibAbs)) { + const libText = await readFile(planLibAbs, 'utf8'); + const hostArr = libText.match(/\$PLAN_API_HOST\s*=\s*(?:_s\s*\(([^)]*)\)|'([^']*)')/); + if (!hostArr) { + out('FAIL', 'Get-5hUsage.ps1: cannot locate the $PLAN_API_HOST constant (disclosure check has no truth to compare against)'); + } else { + const realUrl = hostArr[2] !== undefined + ? hostArr[2] + : Buffer.from( + hostArr[1].split(',') + .map(s => parseInt(s.trim().replace(/^0x/i, ''), 16)) + .filter(n => Number.isFinite(n)) + ).toString('utf8'); + let realHost = null; + try { realHost = new URL(realUrl).hostname; } catch { realHost = null; } + if (!realHost) { + out('FAIL', `Get-5hUsage.ps1: $PLAN_API_HOST is not a parseable URL ("${realUrl}")`); + } else { + out('PASS', `Get-5hUsage.ps1: $PLAN_API_HOST names a valid host (${realHost})`); + + const namedHosts = [ + 'README.md', + join('skills', 'SKILL.md'), + join('skills', 'mcode-island', 'SKILL.md'), + join('scripts', 'lib', 'Get-5hUsage.ps1'), + ]; + let drift = 0, named = 0; + for (const rel of namedHosts) { + const abs = join(PLUGIN_ROOT, rel); + if (!(await exists(abs))) continue; + const text = await readFile(abs, 'utf8'); + // Fresh regex per line: no shared lastIndex state. + for (const [i, line] of text.split(/\r?\n/).entries()) { + for (const hit of line.match(/api\.minimax[a-z]*\.(?:com|io|ai|cn)/gi) || []) { + named++; + if (hit.toLowerCase() !== realHost.toLowerCase()) { + out('FAIL', `${rel}:${i + 1}: names "${hit}" but the code calls "${realHost}"`); + drift++; + } + } + } + } + if (drift === 0) { + out('PASS', `5h disclosure: all ${named} host mention(s) match the real endpoint (${realHost})`); + } + } + } + } else { + out('FAIL', 'scripts/lib/Get-5hUsage.ps1 missing (disclosure check skipped)'); + } + + // 7b. Credential handling (round-21). + // + // The Marketplace review rejected an earlier submission for three + // credential problems in the 5h usage path, and none of them is + // visible in a file listing: + // + // * the request carried "MM-API-Source: Minimax-MCP", i.e. it + // claimed to be a MiniMax-owned surface; + // * the endpoint and several strings were assembled from byte + // arrays, which reads as hiding them from review; + // * the token was stored in the clear in config.json, and an + // ambient environment token was picked up and sent without the + // user ever opting in. + // + // These checks make the contract explicit, so a later edit cannot + // quietly reintroduce any of them. + { + const ps1Files = []; + const walkPs1 = async (dir) => { + let entries = []; + try { entries = await readdir(dir, { withFileTypes: true }); } catch { return; } + for (const e of entries) { + const abs = join(dir, e.name); + if (e.isDirectory()) await walkPs1(abs); + else if (e.name.endsWith('.ps1')) ps1Files.push(abs); + } + }; + await walkPs1(PLUGIN_ROOT); + + let sourceHeader = 0, byteArray = 0; + for (const abs of ps1Files) { + const rel = abs.slice(PLUGIN_ROOT.length + 1).replace(/\\/g, '/'); + const text = await readFile(abs, 'utf8'); + // A header assignment, not a mention in a comment: the + // comment that explains why the header was removed is + // allowed to name it. + if (/['"]MM-API-Source['"]\s*=/.test(text)) { + out('FAIL', `${rel}: sends an MM-API-Source header (it claims to be a MiniMax-owned surface)`); + sourceHeader++; + } + if (/_s\s*\(\s*0x/i.test(text)) { + out('FAIL', `${rel}: builds a string from a byte array (hides it from review)`); + byteArray++; + } + } + if (sourceHeader === 0) out('PASS', 'no .ps1 sends an MM-API-Source header'); + if (byteArray === 0) out('PASS', 'no .ps1 builds a string from a byte array'); + + const setTokenAbs = join(PLUGIN_ROOT, 'set-token.ps1'); + if (await exists(setTokenAbs)) { + const st = await readFile(setTokenAbs, 'utf8'); + if (st.includes('planApiTokenEnc')) { + out('PASS', 'set-token.ps1 stores the token as planApiTokenEnc (encrypted)'); + } else { + out('FAIL', 'set-token.ps1 does not store the token as planApiTokenEnc'); + } + // A plaintext write is the thing the review rejected. The + // legacy field may still be READ (to migrate it), but it must + // never be written back. + const writesPlain = /NotePropertyValue\s+'?\$?Token'|planApiToken'\s*\]\s*=\s*\$Token/.test(st); + const addsPlain = /Add-Member[^\n]*NotePropertyName\s+'planApiToken'/.test(st); + if (writesPlain || addsPlain) { + out('FAIL', 'set-token.ps1 writes a plaintext planApiToken'); + } else { + out('PASS', 'set-token.ps1 never writes a plaintext planApiToken'); + } + } + + if (await exists(planLibAbs)) { + const lib = await readFile(planLibAbs, 'utf8'); + if (lib.includes('allowEnvToken')) { + out('PASS', 'Get-5hUsage.ps1 gates the environment token behind allowEnvToken'); + } else { + out('FAIL', 'Get-5hUsage.ps1 reads the environment token without an allowEnvToken gate'); + } + if (lib.includes('planApiTokenEnc')) { + out('PASS', 'Get-5hUsage.ps1 reads the token from planApiTokenEnc'); + } else { + out('FAIL', 'Get-5hUsage.ps1 does not read the encrypted planApiTokenEnc'); + } + } + } + + // 8. Log growth is bounded (round-20). + // + // Contract: the two append-only logs under %APPDATA%\mcode-island\ + // must cap their own size. mcode-island.ps1 line 7 has documented + // "keep only the last 1KB" since the widget was written, but Dbg was + // a bare Add-Content and never implemented it -- widget.log reached + // 37 MB in 38 days (~3.6 MB/day of POLL lines during active use), and + // nothing ever trimmed it. A stated cap that no code enforces is a + // comment, not a guarantee, so this check asserts the enforcement + // rather than trusting the prose. + for (const t of [ + { rel: 'mcode-island.ps1', fn: 'Dbg' }, + { rel: 'mcode-status-detect.ps1', fn: 'Log-Line' }, + ]) { + const abs = join(PLUGIN_ROOT, t.rel); + if (!(await exists(abs))) { + out('FAIL', `${t.rel} missing (log-cap check skipped)`); + continue; + } + const text = await readFile(abs, 'utf8'); + const body = text.match(new RegExp(`function\\s+${t.fn}\\s*\\([^)]*\\)\\s*\\{([\\s\\S]*?)\\n\\}`)); + if (!body) { + out('FAIL', `${t.rel}: function ${t.fn} not found (cannot verify the log cap)`); + continue; + } + const src = body[1]; + const capped = /Length/i.test(src) && + /(gt|ge|gt\s)/i.test(src) && + /(SetLength|WriteAllText|Truncate|Get-Content|Rotate|-Tail)/i.test(src); + if (!capped) { + out('FAIL', `${t.rel}: ${t.fn} appends with no size cap (log grows without bound)`); + } else { + out('PASS', `${t.rel}: ${t.fn} bounds its own log size`); + } + } + + // 9. Secret-shaped command text is redacted before it is persisted + // (round-20). + // + // Contract: tool-input text (Bash commands, Computer Use keystrokes) + // is written to status.json, to the append-only logs, and rendered on + // an always-on-top pill. The Computer Use branch of Format-ToolSummary + // was redacted in round-19 #1; the Bash branch was not, so a command + // carrying a bearer token or an API key landed on disk verbatim. + // + // There are two independent producers of that text -- the hook path + // (_lib.ps1) and the detector, which re-derives messages from mcode's + // own session log -- so a helper wired into only one of them is a + // silent half-fix. The helper therefore lives in a shared lib, and + // this check asserts BOTH consumers route through it. Asserting the + // helper merely *exists* is the false-green shape: dead code passes it. + const protectLibAbs = join(PLUGIN_ROOT, 'scripts', 'lib', 'Protect-Text.ps1'); + if (!(await exists(protectLibAbs))) { + out('FAIL', 'scripts/lib/Protect-Text.ps1 missing (command secrets are persisted verbatim)'); + } else { + const protectLib = await readFile(protectLibAbs, 'utf8'); + const helper = protectLib.match(/function\s+(Protect-SecretText|Redact-SecretText)/); + if (!helper) { + out('FAIL', 'scripts/lib/Protect-Text.ps1: Protect-SecretText not defined'); + } else { + out('PASS', `scripts/lib/Protect-Text.ps1: ${helper[1]} defined`); + + for (const consumer of [ + { rel: join('io.minimax.mcode', 'hooks', 'scripts', '_lib.ps1'), label: 'hook path' }, + { rel: 'mcode-status-detect.ps1', label: 'detector' }, + ]) { + const abs = join(PLUGIN_ROOT, consumer.rel); + if (!(await exists(abs))) { + out('FAIL', `${consumer.rel} missing (${consumer.label} redaction not verified)`); + continue; + } + const text = await readFile(abs, 'utf8'); + if (!/Protect-Text\.ps1/.test(text)) { + out('FAIL', `${consumer.rel}: does not dot-source scripts/lib/Protect-Text.ps1`); + continue; + } + // The helper is DEFINED in the other file, so every + // occurrence of its name in this consumer is an actual + // invocation -- the dot-source line names the .ps1 file, + // not the function. A consumer that dot-sources but never + // calls therefore scores 0 and fails here, which is the + // whole point of the check. + const uses = (text.match(new RegExp(helper[1], 'g')) || []).length; + if (uses < 1) { + out('FAIL', `${consumer.rel}: dot-sources Protect-Text.ps1 but never calls ${helper[1]} (${consumer.label} is unredacted)`); + } else { + out('PASS', `${consumer.rel}: ${consumer.label} calls ${helper[1]} (${uses} site(s))`); + } + } + } + } + + // 10. Hook invocations survive an install path containing spaces + // (round-20). + // + // Contract: a plugin can legitimately be installed under a path with + // spaces or shell metacharacters, and mcode 0.5.4 explicitly fixed + // its own managed updater for exactly that case. ${PLUGIN_ROOT} is + // substituted at runtime, so the only way to break a spaced path is + // to hand the shell a pre-joined string instead of an argv array. + // This asserts the array form survives -- a "command" that embeds the + // path in a quoted shell string, or an args entry that is itself a + // joined string with embedded quotes, is exactly the regression. + const hooksJsonAbs = join(PLUGIN_ROOT, 'io.minimax.mcode', 'hooks', 'hooks.json'); + if (!(await exists(hooksJsonAbs))) { + out('FAIL', 'io.minimax.mcode/hooks/hooks.json missing (space-path check skipped)'); + } else { + let parsed = null; + try { + parsed = JSON.parse(await readFile(hooksJsonAbs, 'utf8')); + } catch (e) { + out('FAIL', `io.minimax.mcode/hooks/hooks.json: not parseable as JSON (${e.message})`); + } + if (parsed) { + let entries = 0, bad = 0; + for (const [event, list] of Object.entries(parsed.hooks || {})) { + for (const h of (Array.isArray(list) ? list : [list])) { + if (!h || typeof h !== 'object') continue; + entries++; + const cmd = typeof h.command === 'string' ? h.command : ''; + // A command that inlines the plugin root has been + // flattened into a shell string; ${PLUGIN_ROOT} must + // arrive as its own argv element. + if (cmd.includes('${PLUGIN_ROOT}')) { + out('FAIL', `${event}: command embeds ${PLUGIN_ROOT} ("${cmd}") - breaks on install paths with spaces`); + bad++; + } + if (!Array.isArray(h.args)) { + out('FAIL', `${event}: args is not an array (${JSON.stringify(h.args)}) - a joined string is not space-safe`); + bad++; + } else { + for (const a of h.args) { + if (typeof a === 'string' && /["']\s*\S+\s+.*\s*["']/.test(a)) { + out('FAIL', `${event}: args entry looks pre-quoted/joined ("${a}") - breaks on spaced paths`); + bad++; + } + } + } + } + } + if (bad === 0 && entries > 0) { + out('PASS', `hooks.json: all ${entries} hook entr(ies) use argv arrays (space-safe)`); + } + } + } + + // 11. The status.json write path is atomic across processes + // (round-21 review hetaoBackend #1). + // + // Contract: the detector and the agent hooks are two independent + // processes writing the same file. They must share one lock, one + // snapshot and one staging path, or a write that lands between a + // reader's read and its write is lost. + // + // These are the static drift locks. The behavioral proof -- two real + // PowerShell processes interleaving -- lives in + // scripts/test-substep-progress.mjs, section 7, because smoke does + // not execute PowerShell. + { + const storePath = join(PLUGIN_ROOT, 'scripts', 'lib', 'Status-Store.ps1'); + const detectPath = join(PLUGIN_ROOT, 'mcode-status-detect.ps1'); + const notifyPath = join(PLUGIN_ROOT, 'notify-island.ps1'); + + const storeExists = await exists(storePath); + const store = storeExists ? await readFile(storePath, 'utf8') : ''; + const detect = await readFile(detectPath, 'utf8'); + const notify = await readFile(notifyPath, 'utf8'); + + if (!storeExists) { + out('FAIL', 'Status-Store.ps1: the shared status.json protocol lib is missing'); + } else { + // The lock must be a real OS file lock, not a flag file whose + // presence we poll: a flag file has the same check-then-act + // hole the payload has. + if (/FileShare\]::None/.test(store) || /FileShare::None/.test(store)) { + out('PASS', 'Status-Store.ps1: the cross-process lock uses an exclusive OS file lock'); + } else { + out('FAIL', 'Status-Store.ps1: the lock does not use FileShare.None, so it is advisory and does not exclude another writer'); + } + + if (/function\s+Read-StatusSnapshot/.test(store) && /function\s+Write-StatusSnapshot/.test(store)) { + out('PASS', 'Status-Store.ps1: exposes the shared read and write primitives'); + } else { + out('FAIL', 'Status-Store.ps1: the shared read/write primitives are missing'); + } + + // The version counter is what lets a reader tell generations + // apart. It has to be stamped on every write. + if (/\[ordered\]@\{\}/.test(store) && /\$ordered\['version'\]/.test(store)) { + out('PASS', 'Status-Store.ps1: every write stamps the monotonic version'); + } else { + out('FAIL', 'Status-Store.ps1: writes do not stamp a version, so a reader cannot tell generations apart'); + } + + // The staging path must be shared-but-locked, not per-writer: + // two writers staging through the same path is what let one + // rename the other's half-written file into place. + if (/Move-Item -Path \$tmp -Destination \$file -Force/.test(store)) { + out('PASS', 'Status-Store.ps1: the status file is replaced through a staging file'); + } else { + out('FAIL', 'Status-Store.ps1: the status file is not replaced through a staging file'); + } + } + + // The detector must route every write through the shared lib. + if (/Status-Store\.ps1/.test(detect)) { + out('PASS', 'mcode-status-detect.ps1: dot-sources the shared status store lib'); + } else { + out('FAIL', 'mcode-status-detect.ps1: does not dot-source the shared status store lib'); + } + + // The whole point: ONE read per write. A second read inside the + // locked region can still observe a generation the payload was + // not built from. + const writeStatusBody = detect.match(/function Write-StatusUnderLock[\s\S]*?\n}\n/); + if (!writeStatusBody) { + out('FAIL', 'mcode-status-detect.ps1: Write-StatusUnderLock not found'); + } else { + const reads = (writeStatusBody[0].match(/Read-StatusSnapshot/g) || []).length; + if (reads === 1) { + out('PASS', 'mcode-status-detect.ps1: the write path reads status.json exactly once'); + } else { + out('FAIL', `mcode-status-detect.ps1: the write path reads status.json ${reads} times; a second read can observe a different generation than the payload was built from`); + } + } + + if (/Enter-StatusLock/.test(detect) && /Exit-StatusLock/.test(detect)) { + out('PASS', 'mcode-status-detect.ps1: takes and releases the cross-process lock'); + } else { + out('FAIL', 'mcode-status-detect.ps1: does not take/release the cross-process lock around the write'); + } + + // Both writers must use the same protocol, or the lock excludes + // nobody. + if (/Status-Store\.ps1/.test(notify) && /Enter-StatusLock/.test(notify) && /Exit-StatusLock/.test(notify)) { + out('PASS', 'notify-island.ps1: the agent-side writer uses the same locked protocol'); + } else { + out('FAIL', 'notify-island.ps1: the agent-side writer does not use the shared locked protocol, so the lock excludes nobody'); + } + + // A per-writer staging path for the agent side would be a second + // protocol; a SHARED one is the bug. + if (!/"\$statusFile\.tmp"/.test(notify)) { + out('PASS', 'notify-island.ps1: does not stage status.json through a shared path'); + } else { + out('FAIL', 'notify-island.ps1: stages status.json through $statusFile.tmp, the same path the detector used'); + } + } + + // 12. Non-ASCII .ps1 files carry a UTF-8 BOM (round-20 #4). + // + // Contract: every hook and every script the plugin spawns with + // `powershell` (Windows PowerShell 5.1) must survive 5.1's parser. + // 5.1 decodes a BOM-less script using the system ANSI codepage, so a + // file containing non-ASCII comments is mis-decoded and the C# + // here-string in the detector stops being a here-string -- `using + // System;` is then parsed as PowerShell and the script dies before + // its first statement. + // + // .gitattributes pins `*.ps1 text eol=lf`, and its comment claims + // "LF avoids both failure modes". That is backwards: bare LF is part + // of the problem, and the guarantee is what propagates it. Either CRLF + // or a UTF-8 BOM fixes the parse (both verified against 5.1), but + // flipping .gitattributes to CRLF would re-break the Linux-side + // validator the same comment is trying to protect. The BOM is the + // narrower fix: it leaves the line-ending policy alone and is just + // three bytes at the head of the file. + // + // This was not hypothetical. Syncing the committed tree into the + // install directory via the documented `Copy-Item -Recurse -Force` + // flow installed a detector that could not start under 5.1 -- the + // only reason the running copy worked is that it had a BOM that the + // repository does not carry. + const ps1Files = []; + const walk = async (dir) => { + let entries; + try { entries = await readdir(dir, { withFileTypes: true }); } catch { return; } + for (const ent of entries) { + const abs = join(dir, ent.name); + if (ent.isDirectory()) { + if (ent.name === 'node_modules' || ent.name === '.git') continue; + await walk(abs); + } else if (ent.name.endsWith('.ps1')) { + ps1Files.push(abs); + } + } + }; + await walk(PLUGIN_ROOT); + + let missingBom = 0, nonAsciiCount = 0; + for (const abs of ps1Files) { + const buf = await readFile(abs); + const rel = abs.slice(PLUGIN_ROOT.length + 1); + const hasBom = buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf; + let nonAscii = false; + for (let i = 0; i < buf.length; i++) { + if (buf[i] > 0x7f) { nonAscii = true; break; } + } + if (!nonAscii) continue; // ASCII-only is safe without a BOM + nonAsciiCount++; + if (!hasBom) { + out('FAIL', `${rel}: contains non-ASCII but has no UTF-8 BOM (Windows PowerShell 5.1 cannot parse it)`); + missingBom++; + } + } + if (missingBom === 0) { + out('PASS', `ps1 encoding: all ${nonAsciiCount} non-ASCII .ps1 file(s) carry a UTF-8 BOM (${ps1Files.length} scanned)`); + } + + // --- PowerShell read-only automatic variables ------------------------ + // + // Assigning to one of these throws "Cannot overwrite variable PID + // because it is read-only or constant". PowerShell variable names are + // case-insensitive, so `$pid` IS the automatic `$PID` -- and a `catch` + // wrapped around the assignment swallows the error, which makes the + // function look like it ran while it never did. + // + // This is not hypothetical. Focus-TerminalWindow assigned to `$pid`, so + // every click on a session row threw, the catch ate it, and the log + // said "session terminal switched" while the terminal never came + // forward. The same class of bug already bit `$home` in Runtime-Titles + // and `$args` in the detector. + // + // The scanner strips comments and string literals first, so a `$pid` + // mentioned in prose, in a here-string, or in a log message is not a + // hit. `$null = ` is a legal discard and `$Error` / `$args` / + // `$OutputEncoding` are writable, so none of them are in the set. + const RESERVED_PS_VARS = new Set([ + 'pid', 'home', 'pshome', 'psversiontable', 'host', 'iswindows', + 'islinux', 'ismacos', 'psculture', 'psuiculture', 'shellid', + 'myinvocation', 'enabledexperimentalfeatures', 'true', 'false', + ]); + + // Remove comments and string literals, keeping the line structure so + // hits can still be reported with a line number. PowerShell strings + // are single- or double-quoted and escape the quote by doubling it; + // here-strings (@'...'@ / @"..."@) span lines. + const stripLiterals = (src) => { + const outLines = []; + let inHere = null; + for (const line of src.split(/\r?\n/)) { + if (inHere) { + const trimmed = line.trim(); + if (trimmed.startsWith(inHere + '@')) inHere = null; + outLines.push(''); + continue; + } + let res = ''; + let i = 0; + while (i < line.length) { + const ch = line[i]; + if (ch === '#') break; // comment to EOL + if (ch === '@' && (line[i + 1] === "'" || line[i + 1] === '"')) { + const q = line[i + 1]; + if (line.slice(i + 2).trim() === '') { + // multi-line here-string opener + inHere = q; + i = line.length; + continue; + } + i++; // single-line form + continue; + } + if (ch === "'" || ch === '"') { + const q = ch; + i++; + while (i < line.length) { + if (line[i] === q) { + if (line[i + 1] === q) { i += 2; continue; } + i++; + break; + } + if (q === '"' && line[i] === '`') { i += 2; continue; } + i++; + } + res += '""'; + continue; + } + res += ch; + i++; + } + outLines.push(res); + } + return outLines; + }; + + // Parameters that shadow a read-only automatic variable. `param($pid)` + // does not throw at definition time but throws the moment the + // parameter is bound, so it has to be caught statically too. + const findReservedParams = (code) => { + const hits = []; + const re = /\bparam\s*\(/gi; + let m; + while ((m = re.exec(code)) !== null) { + let depth = 1; + let i = m.index + m[0].length; + while (i < code.length && depth > 0) { + if (code[i] === '(') depth++; + else if (code[i] === ')') depth--; + i++; + } + const body = code.slice(m.index + m[0].length, i - 1); + const nm = /\$([A-Za-z_][A-Za-z0-9_]*)/g; + let p; + while ((p = nm.exec(body)) !== null) { + const name = p[1]; + if (!RESERVED_PS_VARS.has(name.toLowerCase())) continue; + let k = p.index - 1; + while (k >= 0 && /\s/.test(body[k])) k--; + if (k >= 0 && body[k] === '=') continue; // default value, not a name + const line = code.slice(0, m.index + p.index).split(/\r?\n/).length; + hits.push({ line, name }); + } + } + return hits; + }; + + const ASSIGN_RE = /(?=\w$:])\$(?:(?:script|global|local|private|using):)*([A-Za-z_][A-Za-z0-9_]*)\s*=(?!=)/g; + const BRACED_ASSIGN_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}\s*=(?!=)/g; + + let reservedHits = 0; + for (const abs of ps1Files) { + const rel = abs.slice(PLUGIN_ROOT.length + 1); + const code = stripLiterals(await readFile(abs, 'utf8')).join('\n'); + const lines = code.split('\n'); + for (let i = 0; i < lines.length; i++) { + for (const re of [ASSIGN_RE, BRACED_ASSIGN_RE]) { + re.lastIndex = 0; + let m; + while ((m = re.exec(lines[i])) !== null) { + const name = m[1]; + if (!RESERVED_PS_VARS.has(name.toLowerCase())) continue; + out('FAIL', `${rel}:${i + 1}: assigns to $${name}, a read-only PowerShell automatic variable -- the assignment throws and a surrounding catch swallows it`); + reservedHits++; + } + } + } + for (const hit of findReservedParams(code)) { + out('FAIL', `${rel}:${hit.line}: parameter $${hit.name} shadows the read-only PowerShell automatic variable`); + reservedHits++; + } + } + if (reservedHits === 0) { + out('PASS', `ps1 reserved-variable guard: no assignment to a read-only PowerShell automatic variable (${ps1Files.length} scanned)`); + } + + // --- SetWindowPos must not resize or move the target ----------------- + // + // SetWindowPos(hwnd, after, X, Y, cx, cy, flags): cx=0/cy=0 and + // X=0/Y=0 only mean "keep the current size / position" when the + // matching flag is present. + // + // missing SWP_NOSIZE -> the window is resized to 0x0. Windows + // Terminal has a min-size fallback, so the window collapses into a + // 480x84 strip instead of disappearing. + // missing SWP_NOMOVE -> the window is really moved to (0,0). + // + // That is not a hypothetical: Focus-Window did exactly this, so every + // "raise the terminal" collapsed it into a strip in the top-left + // corner of the screen. The flags were also written through a local + // variable, so the check has to resolve the variable instead of + // looking at the call site only. + const splitCallArgs = (body) => { + const args = []; + let cur = ''; + let d = 0; + for (const ch of body) { + if (ch === '(') { d++; cur += ch; continue; } + if (ch === ')') { d--; cur += ch; continue; } + if (ch === ',' && d === 0) { args.push(cur.trim()); cur = ''; continue; } + cur += ch; + } + args.push(cur.trim()); + return args; + }; + + const findSetWindowPosCalls = (code) => { + const calls = []; + const re = /\bSetWindowPos\s*\(/g; + let m; + while ((m = re.exec(code)) !== null) { + let depth = 1; + let i = m.index + m[0].length; + while (i < code.length && depth > 0) { + if (code[i] === '(') depth++; + else if (code[i] === ')') depth--; + i++; + } + const body = code.slice(m.index + m[0].length, i - 1); + calls.push({ + line: code.slice(0, m.index).split('\n').length, + args: splitCallArgs(body), + }); + } + return calls; + }; + + // The flags argument is often a local variable ($nofollow / $flags); + // resolve it so the guard sees the flags that are actually passed. + const resolveFlags = (code, flagsArg) => { + if (!flagsArg) return ''; + if (!flagsArg.startsWith('$')) return flagsArg; + const name = flagsArg.slice(1).split(/[^\w]/)[0]; + const m = code.match(new RegExp('\\$' + name + '\\s*=\\s*([^\\r\\n]+)', 'i')); + return m ? m[1] : ''; + }; + + let swpHits = 0; + for (const abs of ps1Files) { + const rel = abs.slice(PLUGIN_ROOT.length + 1); + const code = stripLiterals(await readFile(abs, 'utf8')).join('\n'); + for (const call of findSetWindowPosCalls(code)) { + const a = call.args; + if (a.length < 7) continue; // not a real call site + const flags = resolveFlags(code, a[6]); + const x = a[2].trim(); + const y = a[3].trim(); + const cx = a[4].trim(); + const cy = a[5].trim(); + if (!/\bSWP_NOSIZE\b/.test(flags) && cx === '0' && cy === '0') { + out('FAIL', `${rel}:${call.line}: SetWindowPos passes cx=0/cy=0 without SWP_NOSIZE -- that resizes the window to 0x0 (Windows Terminal clamps it to a ~480x84 strip)`); + swpHits++; + } + if (!/\bSWP_NOMOVE\b/.test(flags) && x === '0' && y === '0') { + out('FAIL', `${rel}:${call.line}: SetWindowPos passes X=0/Y=0 without SWP_NOMOVE -- that moves the window to the top-left corner`); + swpHits++; + } + } + } + if (swpHits === 0) { + out('PASS', `ps1 SetWindowPos guard: no call resizes or moves its target without SWP_NOSIZE / SWP_NOMOVE (${ps1Files.length} scanned)`); + } + finish(); }; 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..de4db964 --- /dev/null +++ b/plugins/antianqi/mcode-island/scripts/test-session-tracker.ps1 @@ -0,0 +1,619 @@ +# 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'))) '文件还在' + +# --- 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 {