diff --git a/plugins/antianqi/mcode-island/LICENSE b/plugins/antianqi/mcode-island/LICENSE new file mode 100644 index 0000000..c26546e --- /dev/null +++ b/plugins/antianqi/mcode-island/LICENSE @@ -0,0 +1,190 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for describing the origin of the Work and + reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Support. While redistributing the Work or + Derivative Works thereof, You may choose to offer, and charge a + fee for, acceptance of support, warranty, indemnity, or other + liability obligations and/or rights consistent with this License. + However, in accepting such obligations, You may act only on Your + own behalf and on Your sole responsibility, not on behalf of any + other Contributor, and only if You agree to indemnify, defend, + and hold each Contributor harmless for any liability incurred by, + or claims asserted against, such Contributor by reason of your + accepting any such warranty or support. + + END OF TERMS AND CONDITIONS + + Copyright 2026 antianqi + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/plugins/antianqi/mcode-island/README.md b/plugins/antianqi/mcode-island/README.md new file mode 100644 index 0000000..3b9705c --- /dev/null +++ b/plugins/antianqi/mcode-island/README.md @@ -0,0 +1,240 @@ +# mcode-island + +A Windows desktop **Dynamic Island** for MiniMax Code agents. A small pill anchored to the +top center of your primary display mirrors the agent's working state in real time +(`idle` / `thinking` / `working` / `waiting` / `done` / `error`). You can leave +the terminal in the background and still see exactly what your agent is doing. + +## Demo + +| idle | thinking | working | waiting | done | error | +|:----:|:--------:|:-------:|:-------:|:----:|:-----:| +| ![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 +widget polls `%APPDATA%\mcode-island\status.json` every 400 ms; the agent (or a +thin wrapper) just writes JSON to that file. + +## What problem this solves + +While the agent works on a long task (compile, install, test, refactor) the user +often switches away from the terminal to read code, check docs, browse the web. +There is no visible progress signal. The agent may also be paused on a +permission prompt or have failed silently. `mcode-island` makes all of that +visible at a glance, without forcing the user to switch back. + +## Copyable example + +### One-line install and run + +```cmd +:: 1. install the plugin into your mcode plugins directory +:: (the location printed by `mcode config` for `plugin_dir`) +set PLUGIN_DIR=%USERPROFILE%\.minimax\plugins +if not exist "%PLUGIN_DIR%\mcode-island" mkdir "%PLUGIN_DIR%\mcode-island" +xcopy /E /I "%~dp0" "%PLUGIN_DIR%\mcode-island" + +:: 2. start the widget +"%PLUGIN_DIR%\mcode-island\mcode-island.cmd" start +``` + +### From a MiniMax Code session + +Ask the agent to read a large file. The agent's `read` invocation triggers +two state pushes — `working` while the tool runs, `done` when it returns: + +```text +> read C:\path\to\very-large-file.cs and summarize the public API + +[widget pulses blue: ⚙ working — read: very-large-file.cs] +[widget goes green: ✓ done — read: very-large-file.cs] +``` + +If the file does not exist: + +```text +> read C:\path\to\missing.cs + +[widget pulses blue: ⚙ working — read: missing.cs] +[widget goes red: ✕ error — read: missing.cs] +``` + +### Wrap every bash call (recommended for power users) + +The shipped `wrap-tool.ps1` is a **status-only wrapper**: it never executes the +command itself (v0.2.1 removed the prior `Invoke-Expression` path to avoid +shell-injection ambiguity). The agent runs the command via mcode's own bash +tool, then calls `wrap-tool.ps1` to publish the state. Two-step pattern: + +```powershell +# 1. Push "working" before the command +& "%PLUGIN_DIR%\mcode-island\wrap-tool.ps1" ` + -Tool bash -Command "npm test" -Description "run tests" + +# 2. After mcode's bash tool returns, push the outcome +& "%PLUGIN_DIR%\mcode-island\wrap-tool.ps1" ` + -Tool bash -Command "npm test" -ExitCode $LASTEXITCODE +``` + +`$LASTEXITCODE` is interpreted as: `0` → `done`, codes in `-WaitingExitCodes` +(default `[1]`) → `waiting`, anything else → `error`. The wrapper returns the +exit code unchanged so the calling shell still sees it. + +If you do not need a custom message, `notify-island.ps1` is a leaner direct +alternative: + +```powershell +& "%PLUGIN_DIR%\mcode-island\notify-island.ps1" -State working -Message "bash : npm test" +& "%PLUGIN_DIR%\mcode-island\notify-island.ps1" -State done -Message "npm test passed" +& "%PLUGIN_DIR%\mcode-island\notify-island.ps1" -State error -Message "npm test failed" +``` + +## Quick start + +1. **Install** — copy this folder into your `~/.minimax/plugins/mcode-island/` + (or any directory you want; the scripts only need to live together). +2. **Start the widget**: + ```cmd + mcode-island.cmd start + ``` + You should see a small dark pill appear at the top center of the screen. +3. **Test a state push** from a new terminal: + ```powershell + & "%PLUGIN_DIR%\mcode-island\notify-island.ps1" -State working -Message "demo" + ``` + The pill should turn blue and pulse for as long as you don't push another state. +4. **Enable logon auto-start** (optional): + ```powershell + & "%PLUGIN_DIR%\mcode-island\autostart.ps1" -Action Enable + ``` + This writes to `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`. No + admin rights required. +5. **Stop when done**: + ```cmd + mcode-island.cmd stop + ``` + +## What is in this package + +``` +mcode-island/ +├── plugin.json # plugin manifest (official 1.0 schema) +├── README.md # this file +├── LICENSE # Apache-2.0 +├── mcode-island.ps1 # WPF widget main loop +├── mcode-island.cmd # CLI shim: start/stop/status/show/pin/... +├── start-island.ps1 # launcher (forces STA + hidden console) +├── 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 +├── autostart.ps1 # register / unregister Windows logon +├── notify-island.ps1 # state-push helper (agents call this) +├── wrap-tool.ps1 # all-in-one bash wrapper +├── skills/mcode-island/SKILL.md # Skill consumed by the agent +└── assets/ # screenshots embedded above +``` + +The whole package is a single portable directory. No installer, no native +binary, no symlink, no `node_modules`. + +## Requirements + +| requirement | version / note | +| ----------------- | ----------------------------------------------------- | +| Windows | 10 1809+ or 11 (uses WPF, `user32` `kernel32`) | +| PowerShell | 5.1 (ships with Windows 10/11) or PowerShell 7 | +| .NET WPF runtime | 4.x (ships with Windows 10/11) | +| execution policy | `Bypass` for this directory; not changed globally | +| network access | **none** — widget does not make any network request | +| accounts | **none** | +| paid services | **none** | + +## Data use + +| data | location | lifetime | purpose | +| ---------------- | ------------------------------------- | ------------------------- | ------------------------------------ | +| `status.json` | `%APPDATA%\mcode-island\` | rewritten every transition | widget polling | +| `caller.json` | `%APPDATA%\mcode-island\` | rewritten every transition | click-to-focus target HWND | +| `config.json` | `%APPDATA%\mcode-island\` | rewritten on drag | pill position, size, opacity | +| `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 | + +**No data leaves the local machine. No telemetry. No network requests.** + +## CLI reference + +```cmd +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 autostart-on REM register for Windows logon +mcode-island autostart-off REM unregister +``` + +The `mcode-island` command is the `.cmd` shim. The Skill tells the agent to call +`notify-island.ps1` directly, not the shim, because PowerShell agents can +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 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`. + +## Test evidence + +This plugin has been exercised on Windows 11 24H2 with PowerShell 5.1, against +a live MiniMax Code session. Empirical evidence (captured during development): + +- 59 state transitions recorded in `island.log` over a multi-hour session + (21 `working` / 14 `done` / 11 `idle` / 6 `waiting` / 5 `thinking` / 1 `error`). +- All 6 states screenshot-verified (`assets/state-*.png`). +- Full state-machine demo (`assets/demo-*.png`): `thinking` → `working` → + `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). +- `wrap-tool.ps1` exit-code semantics: 0 → `done`, 1 → `waiting` (default; + configurable via `-WaitingExitCodes`), other → `error`. As of v0.2.1 the + wrapper no longer executes the command itself; the agent runs the command + through mcode's own bash tool and passes the exit code to `wrap-tool.ps1 + -ExitCode`. + +## Limitations + +- Windows only. The widget uses WPF, `user32`, and `kernel32` P/Invoke. +- One widget per user session. +- `wrap-tool.ps1` only wraps `bash`. Other tools need direct + `notify-island.ps1` calls. +- The widget does not show a progress percentage, token usage, or per-tool + output. v0.2 will. +- File-system polling at 400 ms is not the most efficient design (FileSystemWatcher + was unstable inside WPF in our tests), but it is robust against any kind of + writer and never misses an event. + +## Roadmap + +v0.2 — Tauri (Rust + WebView) rewrite for richer UI, hover-expand, click-through, + per-step progress, media-control integration. +v0.3 — Multi-tab awareness, terminal-agnostic focus restore. + +## License + +Apache-2.0. See [LICENSE](LICENSE). diff --git a/plugins/antianqi/mcode-island/assets/state-done.png b/plugins/antianqi/mcode-island/assets/state-done.png new file mode 100644 index 0000000..b49a138 Binary files /dev/null and b/plugins/antianqi/mcode-island/assets/state-done.png differ diff --git a/plugins/antianqi/mcode-island/assets/state-error.png b/plugins/antianqi/mcode-island/assets/state-error.png new file mode 100644 index 0000000..a3da040 Binary files /dev/null and b/plugins/antianqi/mcode-island/assets/state-error.png differ diff --git a/plugins/antianqi/mcode-island/assets/state-idle.png b/plugins/antianqi/mcode-island/assets/state-idle.png new file mode 100644 index 0000000..5252bb2 Binary files /dev/null and b/plugins/antianqi/mcode-island/assets/state-idle.png differ diff --git a/plugins/antianqi/mcode-island/assets/state-thinking.png b/plugins/antianqi/mcode-island/assets/state-thinking.png new file mode 100644 index 0000000..909ae36 Binary files /dev/null and b/plugins/antianqi/mcode-island/assets/state-thinking.png differ diff --git a/plugins/antianqi/mcode-island/assets/state-waiting.png b/plugins/antianqi/mcode-island/assets/state-waiting.png new file mode 100644 index 0000000..23a9a16 Binary files /dev/null and b/plugins/antianqi/mcode-island/assets/state-waiting.png differ diff --git a/plugins/antianqi/mcode-island/assets/state-working.png b/plugins/antianqi/mcode-island/assets/state-working.png new file mode 100644 index 0000000..2c51aab Binary files /dev/null and b/plugins/antianqi/mcode-island/assets/state-working.png differ diff --git a/plugins/antianqi/mcode-island/assets/wrap-demo.png b/plugins/antianqi/mcode-island/assets/wrap-demo.png new file mode 100644 index 0000000..d6087d9 Binary files /dev/null and b/plugins/antianqi/mcode-island/assets/wrap-demo.png differ diff --git a/plugins/antianqi/mcode-island/autostart.ps1 b/plugins/antianqi/mcode-island/autostart.ps1 new file mode 100644 index 0000000..51ed2dd --- /dev/null +++ b/plugins/antianqi/mcode-island/autostart.ps1 @@ -0,0 +1,45 @@ +# mcode-island - 开机自启管理 +# 用法: +# autostart.ps1 -Enable # 注册到 HKCU\...\Run,开机自动起 +# autostart.ps1 -Disable # 取消 +# autostart.ps1 -Status # 看当前状态 + +param( + [ValidateSet('Enable','Disable','Status')] + [string]$Action = 'Status' +) + +$ErrorActionPreference = 'Stop' +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + +$runKey = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run' +$entryName = 'mcode-island' +$launcher = Join-Path $PSScriptRoot 'start-island.ps1' +$command = "powershell.exe -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File `"$launcher`"" + +switch ($Action) { + 'Enable' { + New-Item -Path $runKey -Force | Out-Null + Set-ItemProperty -Path $runKey -Name $entryName -Value $command + Write-Output "ENABLED: 开机自启已注册" + Write-Output " Key: $runKey\$entryName" + Write-Output " Value: $command" + } + 'Disable' { + if (Get-ItemProperty -Path $runKey -Name $entryName -ErrorAction SilentlyContinue) { + Remove-ItemProperty -Path $runKey -Name $entryName + Write-Output 'DISABLED: 开机自启已取消' + } else { + Write-Output 'DISABLED: 本来就没注册' + } + } + 'Status' { + $existing = Get-ItemProperty -Path $runKey -Name $entryName -ErrorAction SilentlyContinue + if ($existing) { + Write-Output 'ENABLED' + Write-Output " Value: $($existing.$entryName)" + } else { + Write-Output 'DISABLED' + } + } +} diff --git a/plugins/antianqi/mcode-island/mcode-island.cmd b/plugins/antianqi/mcode-island/mcode-island.cmd new file mode 100644 index 0000000..e817ce0 --- /dev/null +++ b/plugins/antianqi/mcode-island/mcode-island.cmd @@ -0,0 +1,55 @@ +@echo off +chcp 65001 >nul +setlocal +set "SCRIPT_DIR=%~dp0" +set "PS=powershell.exe -NoProfile -ExecutionPolicy Bypass" + +if "%1"=="" goto :start +if /i "%1"=="start" goto :start +if /i "%1"=="stop" goto :stop +if /i "%1"=="status" goto :status +if /i "%1"=="show" goto :show +if /i "%1"=="hide" goto :hide +if /i "%1"=="pin" goto :pin +if /i "%1"=="unpin" goto :unpin +if /i "%1"=="autostart-on" goto :autostart_on +if /i "%1"=="autostart-off" goto :autostart_off + +echo Usage: mcode-island {start ^| stop ^| status ^| show ^| hide ^| pin ^| unpin ^| autostart-on ^| autostart-off} +exit /b 1 + +:start +%PS% -File "%SCRIPT_DIR%start-island.ps1" +exit /b %errorlevel% + +:stop +%PS% -File "%SCRIPT_DIR%stop-island.ps1" +exit /b %errorlevel% + +:status +%PS% -File "%SCRIPT_DIR%status-island.ps1" +exit /b %errorlevel% + +:show +%PS% -File "%SCRIPT_DIR%show-island.ps1" +exit /b %errorlevel% + +:hide +echo (Alt+F4 now hides the widget; use "mcode-island show" to bring it back) +exit /b 0 + +:pin +%PS% -File "%SCRIPT_DIR%pin-island.ps1" +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'" +exit /b %errorlevel% + +:autostart_on +%PS% -File "%SCRIPT_DIR%autostart.ps1" -Action Enable +exit /b %errorlevel% + +:autostart_off +%PS% -File "%SCRIPT_DIR%autostart.ps1" -Action Disable +exit /b %errorlevel% diff --git a/plugins/antianqi/mcode-island/mcode-island.ps1 b/plugins/antianqi/mcode-island/mcode-island.ps1 new file mode 100644 index 0000000..cd4334f --- /dev/null +++ b/plugins/antianqi/mcode-island/mcode-island.ps1 @@ -0,0 +1,509 @@ +# mcode 灵动岛 v1 - WPF + PowerShell +# 用法:右键 → 用 PowerShell 运行;或通过 start-island.ps1 启动 + +$ErrorActionPreference = 'Stop' +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + +# 调试日志(写到 %APPDATA%\mcode-island\widget.log,最后 1KB 即可) +$script:dbg = Join-Path $env:APPDATA 'mcode-island\widget.log' +function Dbg($msg) { + $ts = (Get-Date).ToString('HH:mm:ss.fff') + "[$ts] $msg" | Add-Content -Path $script:dbg -Encoding UTF8 +} +Dbg "PID=$PID APART=$([System.Threading.Thread]::CurrentThread.ApartmentState)" + +# WPF 必须在 STA 线程运行。如果当前不是 STA,自动重启。 +if ([System.Threading.Thread]::CurrentThread.ApartmentState -ne 'STA') { + Dbg 'relaunching in STA' + $args2 = @('-NoProfile', '-STA', '-ExecutionPolicy', 'Bypass', '-File', $PSCommandPath) + $args + Start-Process powershell.exe -ArgumentList $args2 -WindowStyle Hidden + exit +} + +# 加载 WPF +Dbg 'loading WPF assemblies' +try { + Add-Type -AssemblyName PresentationFramework + Add-Type -AssemblyName PresentationCore + Add-Type -AssemblyName WindowsBase + Add-Type -AssemblyName System.Xaml + Dbg 'WPF loaded' +} catch { + Dbg "WPF LOAD FAIL: $($_.Exception.Message)" + throw +} + +# P/Invoke:切回 mcode 窗口 +Dbg 'loading WinAPI' +Add-Type @" +using System; +using System.Runtime.InteropServices; +public class WinAPI { + [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd); + [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); + [DllImport("user32.dll")] public static extern bool BringWindowToTop(IntPtr hWnd); + [DllImport("user32.dll")] public static extern bool AllowSetForegroundWindow(uint dwProcessId); + [DllImport("user32.dll")] public static extern bool IsIconic(IntPtr hWnd); + [DllImport("user32.dll")] public static extern bool IsWindow(IntPtr hWnd); + [DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int X, int Y, int cx, int cy, uint uFlags); + [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 bool EnumWindows(EnumProc lpEnumFunc, IntPtr lParam); + [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr hWnd); + public delegate bool EnumProc(IntPtr hWnd, IntPtr lParam); + public static readonly IntPtr HWND_TOPMOST = new IntPtr(-1); + public const uint SWP_NOACTIVATE = 0x0010; + + // 找 pid 的第一个可见窗口 + public static IntPtr FindVisibleWindowForPid(uint targetPid) { + IntPtr found = IntPtr.Zero; + EnumWindows((h, l) => { + uint p; GetWindowThreadProcessId(h, out p); + if (p == targetPid && IsWindowVisible(h)) { + int len = GetWindowTextLength(h); + if (len > 0) { found = h; return false; } + } + return true; + }, IntPtr.Zero); + return found; + } +} +"@ -ErrorAction SilentlyContinue +Dbg 'WinAPI loaded' + +# 配置目录 +$configDir = Join-Path $env:APPDATA 'mcode-island' +if (!(Test-Path $configDir)) { New-Item -ItemType Directory -Path $configDir -Force | Out-Null } +$configFile = Join-Path $configDir 'config.json' +$statusFile = Join-Path $configDir 'status.json' +$logFile = Join-Path $configDir 'island.log' + +# 加载配置(位置等) +$defaultConfig = [PSCustomObject]@{ + x = -1 + y = -1 + width = 320 + height = 60 + opacity = 0.95 + autostart = $false +} +if (Test-Path $configFile) { + try { $cfg = Get-Content $configFile -Raw -Encoding UTF8 | ConvertFrom-Json } + catch { $cfg = $defaultConfig } + foreach ($p in @('x','y','width','height','opacity','autostart')) { + if (-not (Get-Member -InputObject $cfg -Name $p -ErrorAction SilentlyContinue)) { + Add-Member -InputObject $cfg -NotePropertyName $p -NotePropertyValue $defaultConfig.$p + } + } +} else { + $cfg = $defaultConfig + $cfg | ConvertTo-Json | Out-File -FilePath $configFile -Encoding UTF8 +} + +# XAML —— 灵动岛 pill +$xaml = @' + + + + + + + + + + + + + + + + + + 0.5,0.5 + + + + + + + + + + + + + +'@ + +# 加载 XAML +Dbg 'parsing XAML' +try { + $reader = New-Object System.Xml.XmlNodeReader ([xml]$xaml) + $window = [Windows.Markup.XamlReader]::Load($reader) + Dbg 'XAML loaded' +} catch { + Dbg "XAML FAIL: $($_.Exception.Message)" + throw +} + +# 取控件 +$statusDot = $window.FindName('StatusDot') +$pulseRing = $window.FindName('PulseRing') +$pulseScale = $window.FindName('PulseScale') +$stateText = $window.FindName('StateText') +$messageText = $window.FindName('MessageText') +$actionIcon = $window.FindName('ActionIcon') +$pill = $window.FindName('Pill') + +# 状态配色 +$stateMap = @{ + idle = @{ dot='#FF6B7280'; ring='#FF6B7280'; label='mcode'; icon='' } + thinking = @{ dot='#FFEAB308'; ring='#FFEAB308'; label='mcode · 思考中'; icon='' } + working = @{ dot='#FF3B82F6'; ring='#FF3B82F6'; label='mcode · 执行中'; icon='⚙' } + waiting = @{ dot='#FFF59E0B'; ring='#FFF59E0B'; label='mcode · 等待审批'; icon='?' } + done = @{ dot='#FF22C55E'; ring='#FF22C55E'; label='mcode · 已完成'; icon='✓' } + error = @{ dot='#FFEF4444'; ring='#FFEF4444'; label='mcode · 出错'; icon='✕' } +} + +# 颜色转 brush +function C($hex) { return (New-Object System.Windows.Media.SolidColorBrush([System.Windows.Media.ColorConverter]::ConvertFromString($hex))) } + +# 脉冲动画 +$hasPulse = $false +$storyboard = $null +function Start-Pulse { + if ($script:hasPulse) { return } + $script:hasPulse = $true + $animX = New-Object System.Windows.Media.Animation.DoubleAnimation + $animX.From = 1.0; $animX.To = 1.9 + $animX.Duration = [TimeSpan]::FromSeconds(1.0) + $animX.AutoReverse = $true + $animX.RepeatBehavior = [System.Windows.Media.Animation.RepeatBehavior]::Forever + [System.Windows.Media.Animation.Storyboard]::SetTarget($animX, $script:pulseScale) + [System.Windows.Media.Animation.Storyboard]::SetTargetProperty($animX, (New-Object System.Windows.PropertyPath('ScaleX'))) + $animY = New-Object System.Windows.Media.Animation.DoubleAnimation + $animY.From = 1.0; $animY.To = 1.9 + $animY.Duration = [TimeSpan]::FromSeconds(1.0) + $animY.AutoReverse = $true + $animY.RepeatBehavior = [System.Windows.Media.Animation.RepeatBehavior]::Forever + [System.Windows.Media.Animation.Storyboard]::SetTarget($animY, $script:pulseScale) + [System.Windows.Media.Animation.Storyboard]::SetTargetProperty($animY, (New-Object System.Windows.PropertyPath('ScaleY'))) + + $opacityAnim = New-Object System.Windows.Media.Animation.DoubleAnimation + $opacityAnim.From = 0.55; $opacityAnim.To = 0.0 + $opacityAnim.Duration = [TimeSpan]::FromSeconds(1.0) + $opacityAnim.AutoReverse = $true + $opacityAnim.RepeatBehavior = [System.Windows.Media.Animation.RepeatBehavior]::Forever + [System.Windows.Media.Animation.Storyboard]::SetTarget($opacityAnim, $script:pulseRing) + [System.Windows.Media.Animation.Storyboard]::SetTargetProperty($opacityAnim, (New-Object System.Windows.PropertyPath('Opacity'))) + + $sb = New-Object System.Windows.Media.Animation.Storyboard + $sb.Children.Add($animX) | Out-Null + $sb.Children.Add($animY) | Out-Null + $sb.Children.Add($opacityAnim) | Out-Null + $script:storyboard = $sb + $sb.Begin($window) +} +function Stop-Pulse { + $script:hasPulse = $false + if ($script:storyboard) { $script:storyboard.Stop($window) } + $script:pulseScale.ScaleX = 1.0 + $script:pulseScale.ScaleY = 1.0 + $script:pulseRing.Opacity = 0.0 +} + +# 状态更新 +function Update-State { + param([string]$State, [string]$Message) + $s = $script:stateMap[$State] + if (!$s) { $s = $script:stateMap['idle'] } + $script:statusDot.Fill = C $s.dot + $script:pulseRing.Fill = C $s.ring + $script:stateText.Text = $s.label + $script:messageText.Text = if ($Message) { $Message } else { '' } + $script:actionIcon.Text = $s.icon + + if ($State -in @('thinking','working','waiting')) { Start-Pulse } else { Stop-Pulse } + + # 写入 log + $ts = (Get-Date).ToString('HH:mm:ss') + "[$ts] $State :: $Message" | Add-Content -Path $script:logFile -Encoding UTF8 +} + +# 切回调用方窗口(点击 pill 时调用) +function Focus-CallerWindow { + $callerFile = Join-Path $env:APPDATA 'mcode-island\caller.json' + if (!(Test-Path $callerFile)) { Dbg 'FOCUS: no caller file'; return } + + $hwnd = [IntPtr]::Zero + $targetPid = 0 + $targetExe = '' + 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 { '' } + } catch { + Dbg "FOCUS: caller.json parse error" + return + } + if ($targetPid -le 0) { Dbg 'FOCUS: no target'; return } + + # 1) 检查 hwnd 是否还活着 + if ($hwnd -ne [IntPtr]::Zero -and -not [WinAPI]::IsWindow($hwnd)) { + Dbg "FOCUS: hwnd $hwnd dead, re-resolving" + $hwnd = [IntPtr]::Zero + } + + # 2) 进程死了 → 找它的父进程(terminal)兜底 + $proc = Get-Process -Id $targetPid -ErrorAction SilentlyContinue + if (-not $proc) { + Dbg "FOCUS: target PID $targetPid gone, finding parent (terminal)" + $parent = Get-CimInstance Win32_Process -Filter "ProcessId=$targetPid" -ErrorAction SilentlyContinue + if ($parent -and $parent.ParentProcessId -and $parent.ParentProcessId -gt 0) { + $parentProc = Get-Process -Id ([int]$parent.ParentProcessId) -ErrorAction SilentlyContinue + if ($parentProc) { + $targetPid = $parentProc.Id + $targetExe = $parentProc.ProcessName + # 优先用 MainWindowHandle,失败就用第一个可见窗口 + if ($parentProc.MainWindowHandle -ne [IntPtr]::Zero) { + $hwnd = $parentProc.MainWindowHandle + } else { + $hwnd = [WinAPI]::FindVisibleWindowForPid([uint32]$targetPid) + } + Dbg "FOCUS: fall back to parent $($parentProc.ProcessName) PID=$targetPid hwnd=$hwnd" + } + } + if ($hwnd -eq [IntPtr]::Zero) { + Dbg 'FOCUS: no parent fallback available' + return + } + } + # 3) 进程还在但 hwnd 死了(被销毁/重建)→ 找进程的第一个可见窗口 + if ($hwnd -eq [IntPtr]::Zero -or -not [WinAPI]::IsWindow($hwnd)) { + Dbg "FOCUS: 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 "FOCUS: re-resolved to hwnd $hwnd" + } + + 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) + } catch { + Dbg "FOCUS FAIL: $($_.Exception.Message)" + } +} + +# 手动设置焦点目标(右键菜单调用):把当前前台窗口记为 focus target +function Set-FocusTarget-Current { + Add-Type @" +using System; +using System.Runtime.InteropServices; +public class FG2 { + [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); + [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid); +} +"@ -ErrorAction SilentlyContinue + $hwnd = [FG2]::GetForegroundWindow() + if ($hwnd -eq [IntPtr]::Zero) { Dbg 'PIN: no foreground'; return } + $ownerPid = 0 + [FG2]::GetWindowThreadProcessId($hwnd, [ref]$ownerPid) | Out-Null + $proc = Get-Process -Id $ownerPid -ErrorAction SilentlyContinue + 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' + $payload = [PSCustomObject]@{ + callerPid = $myPid + callerSession = $mySession + targetPid = $proc.Id + targetHwnd = [int64]$hwnd + targetTitle = $proc.MainWindowTitle + targetExe = $proc.ProcessName + source = 'manual-pin' + ts = (Get-Date).ToString('o') + } | ConvertTo-Json -Compress + [System.IO.File]::WriteAllText($callerFile, $payload, [System.Text.Encoding]::UTF8) + Dbg ("PIN: target=" + $proc.ProcessName + " PID=" + $proc.Id + " hwnd=" + $hwnd) +} + +# 点击高亮(scale 1.0 -> 0.95 -> 1.0) +function Flash-Click { + $sb = New-Object System.Windows.Media.Animation.Storyboard + $sx = New-Object System.Windows.Media.Animation.DoubleAnimation + $sx.From = 1.0; $sx.To = 0.95; $sx.Duration = [TimeSpan]::FromMilliseconds(80) + $sx.AutoReverse = $true + [System.Windows.Media.Animation.Storyboard]::SetTarget($sx, $script:pill) + [System.Windows.Media.Animation.Storyboard]::SetTargetProperty($sx, (New-Object System.Windows.PropertyPath '(UIElement.RenderTransform).(ScaleTransform.ScaleX)')) + # 用 transient transform + $sy = New-Object System.Windows.Media.Animation.DoubleAnimation + $sy.From = 1.0; $sy.To = 0.95; $sy.Duration = [TimeSpan]::FromMilliseconds(80) + $sy.AutoReverse = $true + [System.Windows.Media.Animation.Storyboard]::SetTarget($sy, $script:pill) + [System.Windows.Media.Animation.Storyboard]::SetTargetProperty($sy, (New-Object System.Windows.PropertyPath '(UIElement.RenderTransform).(ScaleTransform.ScaleY)')) + $sb.Children.Add($sx) | Out-Null + $sb.Children.Add($sy) | Out-Null + $sb.Begin($window) +} + +# 窗口定位 +$screen = [System.Windows.SystemParameters]::WorkArea +if ($cfg.x -lt 0) { + $window.Left = [Math]::Floor(($screen.Width - $cfg.width) / 2) + $screen.Left + $window.Top = $screen.Top + 14 +} else { + $window.Left = $cfg.x + $window.Top = $cfg.y +} +$window.Width = $cfg.width +$window.Height = $cfg.height +$window.Opacity = $cfg.opacity + +# 右键被禁用(用 CLI 'mcode-island pin' 替代,更稳) +$window.Add_MouseRightButtonDown({ + $null = $_.Handled + Dbg 'RIGHT-CLICK ignored, use "mcode-island pin" in mcode terminal' +}) + +# 点击 vs 拖动 +$script:dragStart = $null +$script:didDrag = $false +$script:pill.RenderTransform = New-Object System.Windows.Media.ScaleTransform(1, 1) +$script:pill.RenderTransformOrigin = New-Object System.Windows.Point(0.5, 0.5) +$window.Add_MouseLeftButtonDown({ + $script:dragStart = $_.GetPosition($window) + $script:didDrag = $false +}) +$window.Add_MouseMove({ + if ($script:dragStart -and -not $script:didDrag) { + $cur = $_.GetPosition($window) + $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 {} + } + } +}) +$window.Add_MouseLeftButtonUp({ + try { + if ($script:dragStart -and -not $script:didDrag) { + Dbg 'CLICK detected' + Flash-Click + Focus-CallerWindow + } + } catch { + Dbg "CLICK FAIL: $($_.Exception.Message)" + } + $script:dragStart = $null + $script:didDrag = $false +}) + + +# FileSystemWatcher 监听 status.json(轮询版本,FSW 在 WPF 应用里经常抽风) +$script:lastStatusMtime = $null +$script:lastStatusSig = $null +$showSignalFile = Join-Path $configDir 'show.signal' +$timer = New-Object System.Windows.Threading.DispatcherTimer +$timer.Interval = [TimeSpan]::FromMilliseconds(400) +$timer.Add_Tick({ + # 1) 处理 "show" 信号("mcode-island show" 创建这个文件) + if (Test-Path $showSignalFile) { + try { Remove-Item $showSignalFile -Force -ErrorAction SilentlyContinue } catch {} + $window.Dispatcher.Invoke([Action]{ + $window.Show() + $window.WindowState = [System.Windows.WindowState]::Normal + $window.Activate() + Dbg 'SHOWN via signal file' + }) + } + # 2) 处理 status.json + if (!(Test-Path $statusFile)) { return } + try { + $fi = Get-Item $statusFile + $mtime = $fi.LastWriteTimeUtc.Ticks + if ($mtime -eq $script:lastStatusMtime) { return } + $script:lastStatusMtime = $mtime + $data = Get-Content $statusFile -Raw -Encoding UTF8 | ConvertFrom-Json + $sig = "$($data.state)|$($data.message)|$($data.ts)" + if ($sig -eq $script:lastStatusSig) { return } + $script:lastStatusSig = $sig + Dbg "POLL: $($data.state) :: $($data.message)" + Update-State -State $data.state -Message $data.message + } catch { + Dbg "POLL ERR: $($_.Exception.Message)" + } +}) +$timer.Start() +Dbg 'poll timer started' + +# 启动时读一次 status.json(如果存在) +if (Test-Path $statusFile) { + try { + $init = Get-Content $statusFile -Raw -Encoding UTF8 | ConvertFrom-Json + $script:lastStatusSig = "$($init.state)|$($init.message)|$($init.ts)" + $script:lastStatusMtime = (Get-Item $statusFile).LastWriteTimeUtc.Ticks + Update-State -State $init.state -Message $init.message + } catch {} +} else { + Update-State -State 'idle' -Message '' +} +Dbg 'initial state set' + + +# 关闭事件:保存位置 + 隐藏窗口(不真关进程) +$script:realQuit = $false +$window.Add_Closing({ + $args[1].Cancel = $true + Dbg 'Closing event -> hide' + try { + $cur = Get-Content $script:configFile -Raw -Encoding UTF8 | ConvertFrom-Json + $cur.x = $window.Left + $cur.y = $window.Top + $cur | ConvertTo-Json | Out-File -Path $script:configFile -Encoding UTF8 + } catch {} + $window.Hide() +}) +# 用 Application.Run 启动消息循环(同时支持 Show + Hide + 重新 Show) +Dbg 'starting Application.Run' +$app = New-Object System.Windows.Application +$app.ShutdownMode = [System.Windows.ShutdownMode]::OnExplicitShutdown # 别自己关 +$app.Run($window) | Out-Null +Dbg 'Application.Run returned' + +# 保持进程运行:Application.Run 在 pump 消息,窗口 hide 不会让 Run 返回 +# 真退出时由 mcode-island stop 强杀进程,或显式调 app.Shutdown() + +Dbg 'realQuit set, exiting' diff --git a/plugins/antianqi/mcode-island/mcode-status-detect.ps1 b/plugins/antianqi/mcode-island/mcode-status-detect.ps1 new file mode 100644 index 0000000..96d2257 --- /dev/null +++ b/plugins/antianqi/mcode-island/mcode-status-detect.ps1 @@ -0,0 +1,465 @@ +# mcode-status-detect.ps1 - 后台守护进程 (v0.2.1) +# 监听 mcode session log,实时推断 agent 状态,写到 status.json +# 让 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 +# +# session log(顺序): +# 1. /.minimax/v2/sessions//*/ledger.jsonl(首选,mcode v2 事件流) +# 2. /.minimax/v2/sessions//*/messages.jsonl(fallback) +# +# 状态映射: +# role=user → idle (等用户) +# role=assistant + toolCall → working ": " +# role=assistant + thinking → thinking +# role=assistant + text → idle (刚说完,等用户) +# role=toolResult + !isError → done " 完成" +# role=toolResult + isError → error " 失败" +# mcode 进程不在 → error "mcode 已退出" +# 60s 无新事件 → idle 兜底 +# +# 优先级: +# agent 主动推的状态(status.json 中 source != detector)保留 +# detector 推的 idle / error 可以覆盖 agent 推的(兜底) +# detector 推的 working/thinking/done 不覆盖 agent 推的 +# +# PS 5.1 parser quirk: +# 短 ASCII 字符串字面量在 -eq / state= 后会被解析为 cmdlet 参数 +# 所以所有 ASCII 字符串都用 _s (byte[]) helper 构造 + +param( + [switch]$Once, + [string]$Root = '' # override: /.minimax-code and /.minimax/v2/... +) + +$ErrorActionPreference = 'Stop' +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 +chcp 65001 | Out-Null + +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) +# 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) +# 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) +# source tag +$S_DETECTOR = _s (0x64,0x65,0x74,0x65,0x63,0x74,0x6F,0x72) +# messages (UTF-8 encoded Chinese) +$MSG_USER_WAIT = _s (0xE7,0xAD,0x89,0xE7,0x94,0xA8,0xE6,0x88,0xB7) # 等用户 +$MSG_AGENT_DONE = _s (0x61,0x67,0x65,0x6E,0x74,0x20,0xE5,0x88,0x9A,0xE5,0x9B,0x9E,0xE5,0xA4,0x8D,0xEF,0xBC,0x8C,0xE7,0xAD,0x89,0xE7,0x94,0xA8,0xE6,0x88,0xB7) # agent 刚回复,等用户 +$MSG_FAIL = _s (0xE5,0xA4,0xB1,0xE8,0xB4,0xA5) # 失败 +$MSG_OK = _s (0xE5,0xAE,0x8C,0xE6,0x88,0x90) # 完成 +$MSG_COMPACT = _s (0x73,0x65,0x73,0x73,0x69,0x6F,0x6E,0x20,0xE5,0x8E,0x8B,0xE7,0xBC,0xA9) # session 压缩 +$MSG_MCODE_EXIT = _s (0x6D,0x63,0x6F,0x64,0x65,0x20,0xE8,0xBF,0x9B,0xE7,0xA8,0x8B,0xE5,0xB7,0xB2,0xE9,0x80,0x80,0xE5,0x87,0xBA) # mcode 进程已退出 +$MSG_IDLE_FMT = _s (0xE5,0xB7,0xB2,0xE9,0x9D,0x99,0xE9,0x9C,0xA8,0x20,0x7B,0x30,0x7D,0x73) # 已静默 {0}s +$DOTS = _s (0x2E,0x2E,0x2E) # ... +$FMT_O = _s (0x6F) # 'o' (ISO 8601 timestamp) +$FMT_O_FULL = _s (0x4F) # '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) +# 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 +# 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 + +$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' +$logFile = Join-Path $configDir 'island.log' +$pidFile = Join-Path $configDir 'detect.pid' + +# 解析 mcode 安装根目录(/.minimax-code) +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 } + } + + # 5) 探测 /.minimax-code + try { + $cwd = (Get-Location).Path + $candidate = Join-Path $cwd $MINIMAX_CODE + if (Test-Path $candidate) { return $candidate } + } catch {} + + return $null +} + +# sessions 根目录(优先 mcode 安装目录上一级,再 fallback userprofile) +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')) + if (Test-Path $candidate) { return $candidate } + } + if ($env:USERPROFILE) { + $candidate = Join-Path $env:USERPROFILE $V2_SESSIONS_REL + 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" +} + +# 全局状态 +$script:lastDetectedState = '' +$script:lastMsgsSnapshot = @{ mtime = ''; lastInferred = $null } + +# 5s 缓存:mcode 进程 PID + 最新 session log 路径 +# 避免每秒调 Get-McodePid / 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 + +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')) { + try { + $raw = [System.IO.File]::ReadAllText($f) + $j = $raw | ConvertFrom-Json + $targetPid = [int]$j.pid + $proc = [System.Diagnostics.Process]::GetProcessById($targetPid) + if ($proc -and -not $proc.HasExited) { + # .mcode-active dir is tiny (one or two .json files); prefer the most + # recently started one if multiple happen to exist. + $mtime = [System.IO.File]::GetLastWriteTime($f) + if ($mtime -gt $bestMtime) { + $bestMtime = $mtime + $bestPid = $targetPid + } + } + } catch {} + } + return $bestPid +} + +function Get-LatestSessionFile { + if (-not $sessionsRoot -or -not (Test-Path $sessionsRoot)) { return $null } + # Replace Get-ChildItem -Recurse | Where-Object | Sort-Object | Select-Object + # (a 4-stage pipeline) with a single .NET enumeration + manual mtime scan. + # Same reason as Get-McodePid: avoid the PS 5.1 pipeline-thread leak. + $best = $null + $bestMtime = [DateTime]::MinValue + $bestIsLedger = $false + foreach ($f in [System.IO.Directory]::EnumerateFiles($sessionsRoot, '*.jsonl', 'AllDirectories')) { + $name = [System.IO.Path]::GetFileName($f) + $isLedger = ($name -eq $FNAME_LEDGER) + $isMsgs = ($name -eq $FNAME_MESSAGES) + if (-not $isLedger -and -not $isMsgs) { continue } + try { + $mtime = [System.IO.File]::GetLastWriteTime($f) + } catch { + continue + } + # mcode v0.2.x writes messages.jsonl live; ledger.jsonl is best-effort + # and may be left behind by an old session. Always pick whichever file + # is most recently touched, otherwise a stale ledger would dominate + # and the 60s-idle fallback would fire against ancient timestamps. + if ($mtime -gt $bestMtime) { + $bestMtime = $mtime + $best = $f + $bestIsLedger = $isLedger + } + } + return $best +} + +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) { + # mtime 没变:返回上次推断结果(保持 idle 兜底可达) + return $script:lastMsgsSnapshot.lastInferred + } + $script:lastMsgsSnapshot.mtime = $mtime + $fs = [System.IO.File]::Open($fi.FullName, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite) + try { + $len = $fs.Length + if ($len -eq 0) { return $null } + $lookback = [Math]::Min($len, 65536) + $fs.Seek(-$lookback, [System.IO.SeekOrigin]::End) | Out-Null + $buf = New-Object byte[] $lookback + $fs.Read($buf, 0, $lookback) | Out-Null + $text = [System.Text.Encoding]::UTF8.GetString($buf) + $parts = $text -split "`n" + for ($i = $parts.Length - 1; $i -ge 0; $i--) { + $line = $parts[$i].TrimEnd("`r") + if ($line -and $line.Length -gt 10) { + # ConvertFrom-Json is gated by the mtime cache above, so this + # pipeline only fires when the file actually grew. Acceptable. + $msg = ($line | ConvertFrom-Json) + $script:lastMsgsSnapshot.lastInferred = $msg + return $msg + } + } + return $null + } finally { + $fs.Close() + } + } catch { + return $script:lastMsgsSnapshot.lastInferred + } +} + +function Infer-State($msg) { + if (-not $msg) { return $null } + # ledger.jsonl 事件格式:{kind, phase, action, ...} + # messages.jsonl 消息格式:{message: {role, content, ...}, ...} + $m = $msg.message + if (-not $m) { + # ledger 事件:用 kind/phase 推断 + $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" } + } + if ($kind -eq 'toolCall' -or $kind -eq $S_TOOLCALL) { + return @{ state=$S_WORKING; message="$($msg.toolName) : $($msg.action)" } + } + if ($kind -eq $S_ASSISTANT -and $phase -eq $S_THINKING) { + return @{ state=$S_THINKING; message='' } + } + return $null + } + + $role = [string]$m.role + if ($role -eq $S_USER) { return @{ state=$S_IDLE; message=$MSG_USER_WAIT } } + + if ($role -eq $S_ASSISTANT) { + # Use .Where({}) method on the collection instead of | Where-Object ... + # | Select-Object pipelines: each pipeline is a thread-pool hop on + # PS 5.1 and the completed tasks accumulate over hours-long runs. + $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 } # toolCall wins; we can stop scanning + } + } + + 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 ($hasThink -and -not $hasText) { return @{ state=$S_THINKING; message='' } } + if ($hasText) { return @{ state=$S_IDLE; message=$MSG_AGENT_DONE } } + return @{ state=$S_IDLE; message='' } + } + + 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 ($role -eq $S_COMPACTION) { return @{ state=$S_IDLE; message=$MSG_COMPACT } } + + return $null +} + +function Write-Status($state, $message) { + $tmp = "$statusFile.tmp" + $payload = [PSCustomObject]@{ + state = $state + message = $message + progress = -1 + 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 +} + +function Read-StatusObj { + if (!(Test-Path $statusFile)) { return $null } + try { return ([System.IO.File]::ReadAllText($statusFile) | ConvertFrom-Json) } catch { return $null } +} + +# === 主循环 === +if (-not $Once) { + Set-Content -Path $pidFile -Value $PID -Encoding ASCII + Log-Line "detector started (PID $PID) mcodeRoot=$mcodeRoot" +} + +try { + while ($true) { + $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 + } + if (-not $mcodePid) { + $inferred = @{ state=$S_ERROR; message=$MSG_MCODE_EXIT } + } else { + # 2) 最新 session log:5s 缓存(避免每秒 EnumerateFiles 几千个 .jsonl) + if (($now - $script:lastLatestFileAt) -lt $CACHE_TTL) { + $latestFile = $script:lastLatestFile + } else { + $latestFile = Get-LatestSessionFile + $script:lastLatestFile = $latestFile + $script:lastLatestFileAt = $now + } + if ($latestFile) { + $msg = Read-LastMessage $latestFile + if ($msg) { + $inferred = Infer-State $msg + } + } + } + + # 3) 60s 无活动 → idle 兜底(每次循环都跑,不再被 mtime 缓存屏蔽) + if ($inferred -and $latestFile) { + $curState = [string]$inferred.state + $isSettled = ($curState -eq $S_IDLE) -or ($curState -eq $S_ERROR) + if (-not $isSettled) { + $age = ($now - [System.IO.File]::GetLastWriteTime($latestFile)).TotalSeconds + if ($age -gt 60) { + $inferred = @{ state=$S_IDLE; message=($MSG_IDLE_FMT -f [int]$age) } + } + } + } + + 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 + + $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 + } + + if ($shouldWrite) { + Write-Status $inferred.state $inferred.message + if ($script:lastDetectedState -ne $newState) { + Log-Line "$newState :: $newMsg ($reason)" + $script:lastDetectedState = $newState + } + } + } + + 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) + Log-Line $STOPPED_MSG +} diff --git a/plugins/antianqi/mcode-island/notify-island.ps1 b/plugins/antianqi/mcode-island/notify-island.ps1 new file mode 100644 index 0000000..f20451e --- /dev/null +++ b/plugins/antianqi/mcode-island/notify-island.ps1 @@ -0,0 +1,149 @@ +# mcode 灵动岛 - 状态推送 + 调用方追踪 +# 用法: +# notify-island.ps1 -State working -Message "..." +# +# 副作用: +# 1. 写 status.json(widget 轮询) +# 2. 写 caller.json(widget 点击切回时用)—— 记录调用方进程链和窗口句柄 + +param( + [ValidateSet('idle','thinking','working','waiting','done','error')] + [string]$State = 'idle', + [string]$Message = '', + [int]$Progress = -1 +) + +$ErrorActionPreference = 'Stop' +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 +chcp 65001 | Out-Null + +$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' +$callerFile = Join-Path $configDir 'caller.json' + +# 找当前 console 关联的窗口(这是 Windows Terminal 里那个具体 tab 的 HWND) +Add-Type @" +using System; +using System.Runtime.InteropServices; +public class ConsoleWin { + [DllImport("kernel32.dll")] public static extern IntPtr GetConsoleWindow(); + [DllImport("kernel32.dll")] public static extern bool AttachConsole(uint dwProcessId); + [DllImport("kernel32.dll")] public static extern bool FreeConsole(); + [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint pid); +} +"@ -ErrorAction SilentlyContinue + +function Get-CallerFocusInfo { + # 1) 直接拿自己的 console window + $hwnd = [ConsoleWin]::GetConsoleWindow() + if ($hwnd -ne [IntPtr]::Zero) { + $ownerPid = 0 + [ConsoleWin]::GetWindowThreadProcessId($hwnd, [ref]$ownerPid) | Out-Null + return @{ + targetHwnd = [int64]$hwnd + targetPid = [int]$ownerPid + targetExe = (Get-Process -Id $ownerPid -ErrorAction SilentlyContinue).ProcessName + targetTitle = '' + source = 'console-window' + } + } + + # 2) 临时附到父进程的 console(notify-island 自己是 NonInteractive 启动时) + $parentProc = Get-CimInstance Win32_Process -Filter "ProcessId=$PID" -ErrorAction SilentlyContinue + if ($parentProc -and $parentProc.ParentProcessId -and $parentProc.ParentProcessId -gt 0) { + # 沿链向上找有 console 的祖先 + $current = [int]$parentProc.ParentProcessId + $visited = @() + for ($i = 0; $i -lt 6; $i++) { + if ($visited -contains $current) { break } + $visited += $current + $attached = [ConsoleWin]::AttachConsole([uint32]$current) + if ($attached) { + $hwnd = [ConsoleWin]::GetConsoleWindow() + [ConsoleWin]::FreeConsole() | Out-Null + if ($hwnd -ne [IntPtr]::Zero) { + $ownerPid = 0 + [ConsoleWin]::GetWindowThreadProcessId($hwnd, [ref]$ownerPid) | Out-Null + return @{ + targetHwnd = [int64]$hwnd + targetPid = [int]$ownerPid + targetExe = (Get-Process -Id $ownerPid -ErrorAction SilentlyContinue).ProcessName + targetTitle = '' + source = 'attached-console' + } + } + } + $gp = Get-CimInstance Win32_Process -Filter "ProcessId=$current" -ErrorAction SilentlyContinue + if (-not $gp -or -not $gp.ParentProcessId) { break } + $current = [int]$gp.ParentProcessId + } + } + + # 3) fallback: 进程链里第一个有主窗口的祖先 + $current = $PID + $visited = @() + for ($i = 0; $i -lt 6; $i++) { + if ($visited -contains $current) { return $null } + $visited += $current + $proc = Get-Process -Id $current -ErrorAction SilentlyContinue + if (-not $proc) { return $null } + if ([int64]$proc.MainWindowHandle -ne 0) { + return @{ + targetHwnd = [int64]$proc.MainWindowHandle + targetPid = $proc.Id + targetExe = $proc.ProcessName + targetTitle = $proc.MainWindowTitle + source = 'parent-chain' + } + } + $parent = Get-CimInstance Win32_Process -Filter "ProcessId=$current" -ErrorAction SilentlyContinue + if (-not $parent -or -not $parent.ParentProcessId -or $parent.ParentProcessId -eq 0) { return $null } + $current = [int]$parent.ParentProcessId + } + return $null +} + +$ts = (Get-Date).ToString('o') + +# 写 status.json(widget 轮询用) +# 加 source='agent' 让 detector 识别这是 agent 主动推的,保留不被 working/thinking/done +# 覆盖(detector 推 idle/error 兜底仍然可以接管,符合 takeover 语义)。 +$payload = [PSCustomObject]@{ + state = $State + message = $Message + progress = $Progress + ts = $ts + source = 'agent' +} | ConvertTo-Json -Compress + +# 写 caller.json(widget 点击切回用) +$myPid = $PID +$focusInfo = Get-CallerFocusInfo +$callerData = [PSCustomObject]@{ + callerPid = $myPid + callerSession = (Get-Process -Id $myPid).SessionId + targetPid = if ($focusInfo) { $focusInfo.targetPid } else { 0 } + targetHwnd = if ($focusInfo) { $focusInfo.targetHwnd } else { 0 } + targetTitle = if ($focusInfo) { $focusInfo.targetTitle } else { '' } + targetExe = if ($focusInfo) { $focusInfo.targetExe } else { '' } + source = if ($focusInfo) { $focusInfo.source } else { 'none' } + ts = $ts +} + +# 原子写 +$tmpStatus = "$statusFile.tmp" +$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 + Write-Output "OK: $State - $Message" + if ($focusInfo) { + Write-Output (" (caller via " + $focusInfo.source + ": " + $focusInfo.targetExe + " PID=" + $focusInfo.targetPid + " hwnd=" + $focusInfo.targetHwnd + ")") + } +} catch { + Write-Output "FAIL: $($_.Exception.Message)" + exit 1 +} diff --git a/plugins/antianqi/mcode-island/pin-island.ps1 b/plugins/antianqi/mcode-island/pin-island.ps1 new file mode 100644 index 0000000..36c5f98 --- /dev/null +++ b/plugins/antianqi/mcode-island/pin-island.ps1 @@ -0,0 +1,53 @@ +# mcode 灵动岛 - 把当前前台窗口 pin 为点击目标 +# 在 mcode 终端里直接运行:mcode-island pin +# 效果:把当前前台窗口(也就是 mcode 这个 tab)的 HWND 记下来 +# 之后点 pill,会稳稳切回这里 + +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + +Add-Type @" +using System; +using System.Runtime.InteropServices; +public class PinW { + [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); + [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid); +} +"@ -ErrorAction SilentlyContinue + +$hwnd = [PinW]::GetForegroundWindow() +if ($hwnd -eq [IntPtr]::Zero) { + Write-Output "FAIL: 当前没有前台窗口" + exit 1 +} +$ownerPid = 0 +[PinW]::GetWindowThreadProcessId($hwnd, [ref]$ownerPid) | Out-Null +$proc = Get-Process -Id $ownerPid -ErrorAction SilentlyContinue +if (-not $proc) { + Write-Output "FAIL: 前台窗口进程已退出" + exit 1 +} + +$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' + +$myPid = [int](Get-Process -Id $PID).Id +$mySession = (Get-Process -Id $myPid).SessionId +$payload = [PSCustomObject]@{ + callerPid = $myPid + callerSession = $mySession + targetPid = $proc.Id + targetHwnd = [int64]$hwnd + targetTitle = $proc.MainWindowTitle + targetExe = $proc.ProcessName + source = 'cli-pin' + ts = (Get-Date).ToString('o') +} | ConvertTo-Json -Compress + +$tmpFile = "$callerFile.tmp" +[System.IO.File]::WriteAllText($tmpFile, $payload, [System.Text.Encoding]::UTF8) +Move-Item -Path $tmpFile -Destination $callerFile -Force + +Write-Output ("OK: pin'd " + $proc.ProcessName + " (PID=" + $proc.Id + ", hwnd=" + $hwnd + ")") +Write-Output (" title: " + $proc.MainWindowTitle) +Write-Output " 之后点 pill 就切回这个窗口" diff --git a/plugins/antianqi/mcode-island/plugin.json b/plugins/antianqi/mcode-island/plugin.json new file mode 100644 index 0000000..f32b034 --- /dev/null +++ b/plugins/antianqi/mcode-island/plugin.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "mcode-island", + "version": "0.2.0", + "description": "Windows 桌面灵动岛 (Dynamic Island) 状态窗口:让 mcode agent 把工作状态(idle/thinking/working/waiting/done/error)实时推送到屏幕顶部悬浮 pill,agent 自己忙的时候用户不用切回 mcode 也能看到进度。", + "author": { + "name": "antianqi", + "url": "https://github.com/antianqi" + }, + "homepage": "https://github.com/MiniMax-AI/MiniMax-Code-Plugins/tree/main/plugins/antianqi/mcode-island", + "repository": "https://github.com/MiniMax-AI/MiniMax-Code-Plugins/tree/main/plugins/antianqi/mcode-island", + "license": "Apache-2.0", + "keywords": [ + "mcode", + "windows", + "wpf", + "powershell", + "status", + "ui", + "dynamic-island" + ] +} diff --git a/plugins/antianqi/mcode-island/show-island.ps1 b/plugins/antianqi/mcode-island/show-island.ps1 new file mode 100644 index 0000000..ab963a7 --- /dev/null +++ b/plugins/antianqi/mcode-island/show-island.ps1 @@ -0,0 +1,30 @@ +# mcode 灵动岛 - 把隐藏的 widget 重新叫出来 +# 用法:mcode-island show + +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + +# 看 widget 在不在 +$pidFile = Join-Path $env:APPDATA 'mcode-island\widget.pid' +$widgetPid = (Get-Content $pidFile -ErrorAction SilentlyContinue) -as [int] +if (-not $widgetPid -or $widgetPid -le 0) { + Write-Output 'NOT RUNNING: widget 没在跑,先 mcode-island start' + exit 0 +} +$proc = Get-Process -Id $widgetPid -ErrorAction SilentlyContinue +if (-not $proc -or $proc.HasExited) { + Write-Output "NOT RUNNING: PID $widgetPid 是死的,先 mcode-island start" + exit 0 +} + +# 创建 show 信号(widget 每 400ms 轮询,看到这个就 Show()) +$configDir = Join-Path $env:APPDATA 'mcode-island' +if (!(Test-Path $configDir)) { New-Item -ItemType Directory -Path $configDir -Force | Out-Null } +$signalFile = Join-Path $configDir 'show.signal' + +try { + [System.IO.File]::WriteAllText($signalFile, (Get-Date).ToString('o'), [System.Text.Encoding]::ASCII) + Write-Output "OK: signal sent (widget PID $widgetPid should show up within 1s)" +} catch { + Write-Output "FAIL: $($_.Exception.Message)" + exit 1 +} diff --git a/plugins/antianqi/mcode-island/skills/mcode-island/SKILL.md b/plugins/antianqi/mcode-island/skills/mcode-island/SKILL.md new file mode 100644 index 0000000..b906066 --- /dev/null +++ b/plugins/antianqi/mcode-island/skills/mcode-island/SKILL.md @@ -0,0 +1,154 @@ +--- +name: mcode-island +description: Push the user's terminal out of focus to a Windows desktop Dynamic Island pill so the user can watch your work without switching back to mcode. Use when starting long-running bash/edit/read operations, when a tool needs user approval (waiting), on success (done), or on failure (error). Pair every agent bash / read / write / edit call with a corresponding `notify-island.ps1` state push. +license: Apache-2.0 +compatibility: Requires Windows 10/11 with PowerShell 5.1+ and the mcode-island widget running (started via `mcode-island start` or `autostart.ps1 -Enable`). +metadata: + author: antianqi + version: "0.1.0" +--- + +# mcode-island — 桌面灵动岛状态通知 + +让用户在不切回 mcode 窗口的情况下,从桌面顶部悬浮 pill 上看到你(agent)的实时工作状态。 + +## What it looks like + +A 320×60 pill anchored to the top center of the primary display, always-on-top, dark +theme. Six states with distinct color and motion: + +| state | color | icon | meaning | +| ---------- | -------------- | ---- | -------------------------------- | +| `idle` | gray | — | waiting for user input | +| `thinking` | yellow pulse | — | reasoning, no tool call yet | +| `working` | blue pulse | ⚙ | actively running a tool | +| `waiting` | orange | ? | tool needs user approval / input | +| `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). + +## When to push each state + +| moment | state | example message | +| ----------------------------------------------------- | --------- | ------------------------------ | +| receive user task, start reasoning | `thinking`| (none) | +| about to invoke any tool | `working` | `"bash: npm test"` | +| tool returned 0, before reporting back | `done` | `"3 files modified"` | +| tool needs approval (e.g. permission prompt) | `waiting` | `"bash: needs approval"` | +| tool failed / threw / non-zero exit | `error` | `"compile failed: missing import"` | +| conversation idle, waiting for user | `idle` | (none) | + +**Never push the same state twice in a row** — the widget de-duplicates by +state+message. Push only on transitions, or include a fresh message each time. + +## Copyable example (agent side) + +The plugin ships a thin wrapper `wrap-tool.ps1` that **publishes state only** +(it does NOT execute the command). Run the command via mcode's own bash tool, +then call `wrap-tool.ps1` to publish the outcome: + +```powershell +# Step 1: announce "working" before invoking mcode's bash tool +& "\wrap-tool.ps1" -Tool bash -Command "npm test" -Description "run tests" + +# Step 2: after mcode's bash tool returns, publish the outcome +& "\wrap-tool.ps1" -Tool bash -Command "npm test" -ExitCode $LASTEXITCODE +``` + +`$LASTEXITCODE` is interpreted as: `0` → `done`, codes in `-WaitingExitCodes` +(default `[1]`) → `waiting`, anything else → `error`. The wrapper returns the +exit code unchanged so the calling shell still sees it. + +For other tools (read/write/edit) — and for any state push that is not a single +command — call `notify-island.ps1` directly: + +```powershell +$plugin = "" # directory that contains notify-island.ps1 +& "$plugin\notify-island.ps1" -State thinking +& "$plugin\notify-island.ps1" -State working -Message "read source tree" +& "$plugin\notify-island.ps1" -State done -Message "indexed 142 files" +& "$plugin\notify-island.ps1" -State error -Message "compile failed: missing import" +& "$plugin\notify-island.ps1" -State waiting -Message "permission prompt" +``` + +`` 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. + +## Expected result + +After each push, 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. + +## User-side management + +```cmd +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 window +mcode-island pin REM lock focus target to current foreground window +mcode-island unpin REM clear focus target +mcode-island autostart-on REM register for Windows logon +mcode-island autostart-off REM unregister +``` + +To enable login auto-start, the user runs once: + +```powershell +& "\autostart.ps1" -Enable +``` + +This writes to `HKCU\Software\Microsoft\Windows\CurrentVersion\Run` — no admin +rights required. + +## Runtime data + +All widget state lives under `%APPDATA%\mcode-island\`: + +| file | purpose | +| ---------------- | ----------------------------------------------------- | +| `status.json` | current state (widget polls this every 400 ms) | +| `caller.json` | originating terminal HWND / PID (for click-to-focus) | +| `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 | +| `widget.log` | widget internal debug log | +| `show.signal` | transient file written by `mcode-island show` | + +No data leaves the local machine. The plugin does not make any network request. + +## What is in this package + +``` +mcode-island/ +├── plugin.json # plugin manifest +├── README.md # full user-facing docs +├── LICENSE # Apache-2.0 +├── mcode-island.ps1 # WPF widget main loop +├── mcode-island.cmd # CLI shim (start/stop/status/...) +├── start-island.ps1 # launch the widget in STA +├── stop-island.ps1 # stop the widget +├── status-island.ps1 # print widget state +├── show-island.ps1 # re-raise hidden widget +├── pin-island.ps1 # lock focus target to foreground +├── autostart.ps1 # register/unregister Windows logon +├── notify-island.ps1 # state-push helper (agents call this) +├── wrap-tool.ps1 # all-in-one bash wrapper +├── skills/mcode-island/SKILL.md # this file +└── assets/ # screenshots used in the README +``` + +## Limitations and known constraints + +- Windows 10/11 only (uses WPF and `presentationframework`). +- Single widget per user session. +- No hover-expand, no media-control integration yet — see the `v0.2` roadmap in + the upstream issue tracker. +- `wrap-tool.ps1` only wraps `bash`. For `read` / `write` / `edit` the agent + must call `notify-island.ps1` itself before and after the tool call. diff --git a/plugins/antianqi/mcode-island/start-detect-island.ps1 b/plugins/antianqi/mcode-island/start-detect-island.ps1 new file mode 100644 index 0000000..e86b46d --- /dev/null +++ b/plugins/antianqi/mcode-island/start-detect-island.ps1 @@ -0,0 +1,32 @@ +# 启动 detector 守护进程(独立进程跑 mcode-status-detect.ps1) +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + +$configDir = Join-Path $env:APPDATA 'mcode-island' +if (!(Test-Path $configDir)) { New-Item -ItemType Directory -Path $configDir -Force | Out-Null } +$pidFile = Join-Path $configDir 'detect.pid' +$detectScript = Join-Path $PSScriptRoot 'mcode-status-detect.ps1' + +# 防重复:校验已有 PID 是否真在跑 detector +function Test-IsDetectorProcess($proc, $detectPath) { + if (-not $proc -or $proc.HasExited) { return $false } + $cmd = (Get-CimInstance Win32_Process -Filter "ProcessId=$($proc.Id)" -ErrorAction SilentlyContinue).CommandLine + if (-not $cmd) { return $false } + return $cmd.IndexOf($detectPath, [System.StringComparison]::OrdinalIgnoreCase) -ge 0 +} + +if (Test-Path $pidFile) { + $old = (Get-Content $pidFile -ErrorAction SilentlyContinue) -as [int] + if ($old -and $old -gt 0) { + $existing = Get-Process -Id $old -ErrorAction SilentlyContinue + if ($existing -and (Test-IsDetectorProcess $existing $detectScript)) { + Write-Output ("detector already running (PID " + $old + ")") + exit 0 + } + Remove-Item $pidFile -Force -ErrorAction SilentlyContinue + } +} + +$args = @('-NoProfile', '-STA', '-WindowStyle', 'Hidden', '-ExecutionPolicy', 'Bypass', '-File', "`"$detectScript`"") +$proc = Start-Process powershell.exe -ArgumentList $args -PassThru +Set-Content -Path $pidFile -Value $proc.Id -Encoding ASCII +Write-Output ("detector started (PID " + $proc.Id + ")") diff --git a/plugins/antianqi/mcode-island/start-island.ps1 b/plugins/antianqi/mcode-island/start-island.ps1 new file mode 100644 index 0000000..c0b47a6 --- /dev/null +++ b/plugins/antianqi/mcode-island/start-island.ps1 @@ -0,0 +1,61 @@ +# mcode 灵动岛 - 启动器 +# 用法:双击这个脚本,或命令行执行 +# 效果:在新进程中以 STA 模式启动 widget,自身立即退出,不留黑窗 +# 副作用:把 widget 的 PID 写到 %APPDATA%\mcode-island\widget.pid(status/stop 用) + +$ErrorActionPreference = 'Stop' +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + +$configDir = Join-Path $env:APPDATA 'mcode-island' +if (!(Test-Path $configDir)) { New-Item -ItemType Directory -Path $configDir -Force | Out-Null } + +$widget = Join-Path $PSScriptRoot 'mcode-island.ps1' +$pidFile = Join-Path $configDir 'widget.pid' + +# 防止重复启动:先校验已有 PID 是否真的在跑 widget(不是 PID 复用) +function Test-IsWidgetProcess($proc, $widgetPath) { + if (-not $proc -or $proc.HasExited) { return $false } + $cmd = (Get-CimInstance Win32_Process -Filter "ProcessId=$($proc.Id)" -ErrorAction SilentlyContinue).CommandLine + if (-not $cmd) { return $false } + # CommandLine contains the widget script path (case-insensitive) + return $cmd.IndexOf($widgetPath, [System.StringComparison]::OrdinalIgnoreCase) -ge 0 +} + +if (Test-Path $pidFile) { + $old = Get-Content $pidFile -ErrorAction SilentlyContinue + if ($old) { + $existing = Get-Process -Id ([int]$old) -ErrorAction SilentlyContinue + if ($existing -and (Test-IsWidgetProcess $existing $widget)) { + Write-Output ("已在运行(PID " + $old + ")") + exit 0 + } + # Stale PID file (process dead or PID reused) — clear and continue + Remove-Item $pidFile -Force -ErrorAction SilentlyContinue + } +} + +# 新进程启动 widget +$args = @('-NoProfile', '-STA', '-WindowStyle', 'Hidden', '-ExecutionPolicy', 'Bypass', '-File', "`"$widget`"") +$proc = Start-Process powershell.exe -ArgumentList $args -PassThru + +# 写 PID(先写,后面 status / stop 都靠这个) +Set-Content -Path $pidFile -Value $proc.Id -Encoding ASCII + +# 等待 widget 起来(最多 8 秒),用 .ps1 已加载 WPF 窗口作为 readiness 信号 +$widgetWindowClass = 'HwndWrapper[DefaultDomain*' # WPF 窗口类名前缀 +$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 } + } catch {} +} + +if ($ok) { + Write-Output ("mcode-island 已启动 (PID " + $proc.Id + ",已就绪)") +} else { + Write-Output ("mcode-island 已启动 (PID " + $proc.Id + ",等待中...)") +} diff --git a/plugins/antianqi/mcode-island/status-detect-island.ps1 b/plugins/antianqi/mcode-island/status-detect-island.ps1 new file mode 100644 index 0000000..6e7d9e1 --- /dev/null +++ b/plugins/antianqi/mcode-island/status-detect-island.ps1 @@ -0,0 +1,17 @@ +# 看 detector 状态 +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + +$pidFile = Join-Path $env:APPDATA 'mcode-island\detect.pid' +if (!(Test-Path $pidFile)) { Write-Output 'NOT RUNNING'; exit 0 } +$targetPid = (Get-Content $pidFile -ErrorAction SilentlyContinue) -as [int] +$p = Get-Process -Id $targetPid -ErrorAction SilentlyContinue +if ($p -and -not $p.HasExited) { + Write-Output ("RUNNING PID=" + $p.Id + " StartTime=" + $p.StartTime.ToString('HH:mm:ss')) + $sf = Join-Path $env:APPDATA 'mcode-island\status.json' + if (Test-Path $sf) { + Write-Output '--- last status.json ---' + Get-Content $sf -Raw + } +} else { + Write-Output ("NOT RUNNING (stale pid " + $targetPid + ")") +} diff --git a/plugins/antianqi/mcode-island/status-island.ps1 b/plugins/antianqi/mcode-island/status-island.ps1 new file mode 100644 index 0000000..e71cf37 --- /dev/null +++ b/plugins/antianqi/mcode-island/status-island.ps1 @@ -0,0 +1,26 @@ +# 看 widget 状态 +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + +$pidFile = Join-Path $env:APPDATA 'mcode-island\widget.pid' +if (!(Test-Path $pidFile)) { + Write-Output 'NOT RUNNING (no pid file)' + exit 0 +} + +$widgetPid = (Get-Content $pidFile -ErrorAction SilentlyContinue) -as [int] +if (-not $widgetPid -or $widgetPid -le 0) { + Write-Output 'NOT RUNNING (invalid pid)' + exit 0 +} + +$proc = Get-Process -Id $widgetPid -ErrorAction SilentlyContinue +if ($proc -and -not $proc.HasExited) { + Write-Output ("RUNNING PID=" + $proc.Id + " Session=" + $proc.SessionId + " StartTime=" + $proc.StartTime.ToString('HH:mm:ss')) + $logFile = Join-Path $env:APPDATA 'mcode-island\island.log' + if (Test-Path $logFile) { + Write-Output '--- recent log ---' + Get-Content $logFile -Tail 5 -Encoding UTF8 + } +} else { + Write-Output ("NOT RUNNING (stale pid $widgetPid)") +} diff --git a/plugins/antianqi/mcode-island/stop-detect-island.ps1 b/plugins/antianqi/mcode-island/stop-detect-island.ps1 new file mode 100644 index 0000000..fee98d7 --- /dev/null +++ b/plugins/antianqi/mcode-island/stop-detect-island.ps1 @@ -0,0 +1,23 @@ +# 停掉 detector(校验 PID + cmdline 防误杀) +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + +$pidFile = Join-Path $env:APPDATA 'mcode-island\detect.pid' +$detectScript = 'mcode-status-detect.ps1' # cmdline 匹配 +if (!(Test-Path $pidFile)) { Write-Output 'NOT RUNNING'; exit 0 } + +$targetPid = (Get-Content $pidFile -ErrorAction SilentlyContinue) -as [int] +if ($targetPid -and $targetPid -gt 0) { + $p = Get-Process -Id $targetPid -ErrorAction SilentlyContinue + if ($p -and -not $p.HasExited) { + $cmd = (Get-CimInstance Win32_Process -Filter "ProcessId=$targetPid" -ErrorAction SilentlyContinue).CommandLine + if ($cmd -and $cmd.IndexOf($detectScript, [System.StringComparison]::OrdinalIgnoreCase) -ge 0) { + Stop-Process -Id $targetPid -Force + Write-Output ("stopped PID " + $targetPid) + } else { + Write-Output ("REFUSED: PID " + $targetPid + " is alive but not the detector (PID reuse suspected). Refusing to kill.") + } + } else { + Write-Output ("NOT RUNNING (stale pid " + $targetPid + ")") + } +} +Remove-Item $pidFile -Force -ErrorAction SilentlyContinue diff --git a/plugins/antianqi/mcode-island/stop-island.ps1 b/plugins/antianqi/mcode-island/stop-island.ps1 new file mode 100644 index 0000000..c5a803e --- /dev/null +++ b/plugins/antianqi/mcode-island/stop-island.ps1 @@ -0,0 +1,32 @@ +# 停掉 widget +# 校验 PID 文件 + cmdline,防止误杀 PID 复用的不相关进程 +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + +$configDir = Join-Path $env:APPDATA 'mcode-island' +$pidFile = Join-Path $configDir 'widget.pid' +$widgetScript = 'mcode-island.ps1' # 用于 cmdline 匹配 + +if (!(Test-Path $pidFile)) { + Write-Output 'NOT RUNNING' + exit 0 +} + +$widgetPid = (Get-Content $pidFile -ErrorAction SilentlyContinue) -as [int] +if ($widgetPid -and $widgetPid -gt 0) { + $proc = Get-Process -Id $widgetPid -ErrorAction SilentlyContinue + if ($proc -and !$proc.HasExited) { + # 验证 cmdline 含 widget 脚本名(防 PID 复用) + $cmd = (Get-CimInstance Win32_Process -Filter "ProcessId=$widgetPid" -ErrorAction SilentlyContinue).CommandLine + if ($cmd -and $cmd.IndexOf($widgetScript, [System.StringComparison]::OrdinalIgnoreCase) -ge 0) { + Stop-Process -Id $widgetPid -Force + Write-Output ("stopped PID " + $widgetPid) + } else { + Write-Output ("REFUSED: PID " + $widgetPid + " is alive but not the widget (PID reuse suspected). Refusing to kill.") + } + } else { + Write-Output ("NOT RUNNING (stale pid " + $widgetPid + ")") + } +} else { + Write-Output 'NOT RUNNING' +} +Remove-Item $pidFile -Force -ErrorAction SilentlyContinue diff --git a/plugins/antianqi/mcode-island/wrap-tool.ps1 b/plugins/antianqi/mcode-island/wrap-tool.ps1 new file mode 100644 index 0000000..77960b5 --- /dev/null +++ b/plugins/antianqi/mcode-island/wrap-tool.ps1 @@ -0,0 +1,64 @@ +# mcode 灵动岛 - 工具调用 wrapper (v0.2.1: status-only) +# 用法(在 mcode 里): +# wrap-tool.ps1 -Tool bash -Command "npm test" # 推 working +# wrap-tool.ps1 -Tool bash -Command "npm test" -ExitCode 0 # 推 done +# wrap-tool.ps1 -Tool bash -Command "npm test" -ExitCode 1 # 推 waiting +# wrap-tool.ps1 -Tool bash -Command "npm test" -ExitCode 2 # 推 error +# +# 设计:v0.2.1 改为"只发通知不执行命令"。v0.1 的 Invoke-Expression 在 review 中 +# 被指出 injection 风险(任意 PS 代码执行)。现在 agent 自己在 mcode bash tool +# 里跑命令,然后调本脚本只发状态,shell 边界由 mcode 自己处理。 +# +# 行为: +# 1. 推 working -Message " " (如果没传 -ExitCode) +# 2. 推 done/error/waiting based on -ExitCode (如果传了 -ExitCode) +# 3. 退出码原样返回(done→0, waiting→waiting-exit-code, error→error-exit-code) + +param( + [Parameter(Mandatory=$true)] + [ValidateSet('bash','read','write','edit','glob','grep','web','task','notebook')] + [string]$Tool, + + [string]$Command = '', + [string]$Path = '', + [string]$Pattern = '', + [string]$Glob = '', + [string]$Description = '', + [int]$ExitCode = -1, # -1 = not yet run, push working; 0/非0 = push done/waiting/error + [int[]]$WaitingExitCodes = @(1) # 这些退出码视为 "waiting"(需要审批) +) + +$ErrorActionPreference = 'Continue' +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 +chcp 65001 > $null + +$notify = Join-Path $PSScriptRoot 'notify-island.ps1' + +# 拼一个"简述"显示在 widget 上 +$brief = $Description +if (-not $brief) { + if ($Command) { $brief = ($Command.Substring(0, [Math]::Min(60, $Command.Length))) } + elseif ($Path) { $brief = $Path } + elseif ($Pattern) { $brief = $Pattern } + elseif ($Glob) { $brief = $Glob } + else { $brief = '' } +} +$brief = $brief -replace "`r`n", ' ' -replace "`n", ' ' + +if ($ExitCode -lt 0) { + # 还没跑完:推 working + & $notify -State working -Message "$Tool : $brief" | Out-Null + exit 0 +} + +# 跑完了:根据退出码推 done/waiting/error +if ($ExitCode -eq 0) { + & $notify -State done -Message "$Tool 完成" | Out-Null + exit 0 +} elseif ($WaitingExitCodes -contains $ExitCode) { + & $notify -State waiting -Message "$Tool 等待审批 (exit=$ExitCode)" | Out-Null + exit $ExitCode +} else { + & $notify -State error -Message "$Tool 失败 (exit=$ExitCode)" | Out-Null + exit $ExitCode +}