diff --git a/.github/workflows/mcode-island-windows.yml b/.github/workflows/mcode-island-windows.yml new file mode 100644 index 0000000..79b363d --- /dev/null +++ b/.github/workflows/mcode-island-windows.yml @@ -0,0 +1,313 @@ +name: mcode-island (windows-latest) + +on: + pull_request: + paths: + - 'plugins/antianqi/mcode-island/**' + - '.github/workflows/mcode-island-windows.yml' + push: + branches: [main] + paths: + - 'plugins/antianqi/mcode-island/**' + - '.github/workflows/mcode-island-windows.yml' + +# Round-5 review (hetaoBackend, 2026-08-28T08:22:25Z) on commit 38413d9: +# "The remaining blocker is executable platform evidence. This is a +# Windows/PowerShell/WPF/Win32 plugin ... but the PR adds no workflow +# and this head has no Actions run. The Node smoke is static and does +# not execute the PowerShell scripts. Please add a windows-latest job +# that at minimum parses all `.ps1` files and exercises token set/show/clear +# in an isolated data directory, mocked usage-API behavior, and hook +# stdin/stdout paths without opening the real UI." +# +# This workflow exercises those four contract surfaces on windows-latest +# without requiring a desktop session, a real OAuth token, or a real +# network round-trip to api.minimaxi.com. It does not open the WPF UI +# (no explorer.exe, no logon session) and does not run the +# mcode-status-detect.ps1 main loop (which would block for 60s+ in +# CI and require a real mcode install). The detector's 5h usage path +# is exercised in step 4 by re-using the same token + URL the detector +# uses, pointed at a localhost HttpListener (in a Start-Job, sync +# wait) that records the Authorization header and returns a synthetic +# model_remains JSON. +# +# `[code]smith` is SKIPPED on this repository, so this windows-latest +# job is the CI evidence for the round-5 review. + +permissions: + contents: read + +jobs: + mcode-island-windows: + name: mcode-island on windows-latest (parse + token + hook + mock-API) + runs-on: windows-latest + timeout-minutes: 10 + defaults: + run: + shell: pwsh + steps: + - name: Checkout + uses: actions/checkout@v4 + + # 1) Parse all .ps1 files. Static syntax check; if any .ps1 + # fails to parse, CI fails. A future change that introduces + # a PowerShell syntax error anywhere in the plugin (main + # script, hooks/scripts/*.ps1, set-token, notify-island, + # detector, ...) will fail this step. Negative-injection: + # try adding a stray `}` to any .ps1 and this step fails. + - name: Parse all .ps1 files (round-5 requirement #1) + run: | + $root = Resolve-Path 'plugins/antianqi/mcode-island' + $files = @(Get-ChildItem -Path $root -Recurse -Filter *.ps1) + if ($files.Count -eq 0) { throw "No .ps1 files found under $root" } + Write-Host "Parsing $($files.Count) .ps1 files under $root..." + $bad = 0 + foreach ($f in $files) { + $errs = $null + $null = [System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$errs) + if ($errs -and $errs.Count -gt 0) { + $rel = $f.FullName.Substring($root.Path.Length + 1) -replace '\\', '/' + Write-Host "PARSE FAIL: $rel" + $errs | ForEach-Object { + Write-Host " line $($_.Extent.StartLineNumber):col $($_.Extent.StartColumnNumber) $($_.Message)" + } + $bad++ + } + } + if ($bad -gt 0) { throw "$bad / $($files.Count) .ps1 files failed to parse" } + Write-Host "OK: $($files.Count) .ps1 files parsed without syntax errors" + + # 2) Token set/show/clear in an isolated data directory. We + # redirect $env:APPDATA in **this step's process** (not + # just to GITHUB_ENV for future steps), so the + # set-token.ps1 invocations below write into + # $RUNNER_TEMP\mcode-island-apphome\ instead of the + # runner's real APPDATA. The detector's + # $APPDATA\mcode-island\config.json path is followed + # exactly; only the root is swapped. + - name: Token set / show / clear roundtrip in isolated APPDATA (round-5 requirement #2) + env: + FAKE_TOKEN: 'ci-fake-oauth-token-1234567890abcdef' + run: | + # Force UTF-8 in the parent so the round-trip + # parent.write -> child.stdout -> child.Console -> + # parent.capture chain survives the + # PowerShell-5.1-on-non-UTF-8-system codepage trap + # (children inherit the parent's [Console]::OutputEncoding + # at process start; if parent is cp1252/GBK and child + # sets UTF-8 internally, captured strings can be + # truncated on the way back). + [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + $OutputEncoding = [System.Text.Encoding]::UTF8 + try { chcp 65001 | Out-Null } catch {} + + $apphome = New-Item -ItemType Directory -Path (Join-Path $env:RUNNER_TEMP 'mcode-island-apphome') -Force + $env:APPDATA = $apphome.FullName + # Also export to GITHUB_ENV so step 3 (hook) and step 4 + # (mock usage-API) inherit the same isolated APPDATA. + "APPDATA=$($apphome.FullName)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + Write-Host "Isolated APPDATA: $($apphome.FullName)" + + # Defensive: unset any pre-existing MINIMAX_OAUTH_TOKEN + # / MINIMAX_API_KEY so the set-token show step below + # must report the config.json source (its fallback + # contract). GitHub Actions does not export these by + # default, but a future PR could add a workflow-level + # env: that pollutes this test. Step 4 sets + # MINIMAX_OAUTH_TOKEN explicitly for its own test. + foreach ($name in 'MINIMAX_OAUTH_TOKEN', 'MINIMAX_API_KEY') { + if (Test-Path "env:$name") { Remove-Item "env:$name" -ErrorAction SilentlyContinue } + } + + $set = 'plugins/antianqi/mcode-island/set-token.ps1' + + # 2a) set: write token + $r1 = (& $set $env:FAKE_TOKEN | Out-String).Trim() + if ($r1 -notmatch '^已写入') { throw "set: expected '已写入' header, got: $r1" } + $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))" + } + + # 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" + } + $expectedMask = ($env:FAKE_TOKEN).Substring(0, [Math]::Min(10, $env:FAKE_TOKEN.Length)) + '\.\.\.' + if ($r2 -notmatch $expectedMask) { + throw "show after set: expected masked prefix matching '$expectedMask', got: $r2" + } + + # 2c) clear: remove token + $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" + } + + # 2d) show: verify it reports the unconfigured state + $r4 = (& $set -Show | Out-String).Trim() + if ($r4 -ne 'token 未配置') { throw "show after clear: expected 'token 未配置', got: $r4" } + + Write-Host "Token set/show/clear roundtrip OK (4 / 4 checks)" + + # 3) Hook stdin/stdout: pipe a synthetic PreToolUse event into + # the bundled io.minimax.mcode/hooks/scripts/pre-tool-use.ps1 + # hook entry. The hook reads the JSON event from stdin + # (_lib.ps1 Read-HookStdin), formats the tool summary, and + # pushes a `working` state to status.json via notify-island. + # The push is asserted by reading back + # $APPDATA\mcode-island\status.json (the same path the + # WPF widget polls at runtime). Negative-injection: change + # pre-tool-use.ps1 to push a wrong state, this step fails. + # + # PowerShell 5.1 caveat: `$string | & script.ps1` does + # NOT rewire the child process's stdin; only stdout/stderr + # cross the pipeline. We must launch the hook as a real + # child process with an explicit -RedirectStandardInput + # so [Console]::In.ReadToEnd() inside the hook sees the + # JSON. The CI's isolated APPDATA (set in step 2) is + # inherited via $env:APPDATA below. + - name: Hook stdin / stdout (PreToolUse) writes status.json (round-5 requirement #4) + run: | + $hook = 'plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/pre-tool-use.ps1' + $stdinFile = Join-Path $env:RUNNER_TEMP 'hook-stdin-pretooluse.json' + # Build the JSON as a single-line PowerShell single-quoted + # string instead of using a here-doc. A here-doc (`@'...'@`) + # in this `run: |` YAML block triggered a YAML parse error + # in the v1 commit: the leading `@'` after a `run: |` block + # scalar confused js-yaml (it tried to treat `@'` as a + # block-scalar start, then ran into a `}` / `,` and a `\` + # backslash on the same line and gave up at line 187). + # The single-line string is a 1:1 content match for the + # previous here-doc body and is portable across the YAML + # parser GitHub Actions uses. + $stdinJson = '{"session_id":"ci-fake-session","transcript_path":"C:\\fake\\transcript","cwd":"C:\\fake\\cwd","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"echo ci-pretooluse-test"}}' + Set-Content -Path $stdinFile -Value $stdinJson -Encoding utf8 -NoNewline + + $p = Start-Process -FilePath 'powershell' ` + -ArgumentList @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $hook) ` + -NoNewWindow -RedirectStandardInput $stdinFile ` + -PassThru + $p.WaitForExit() + if ($p.ExitCode -ne 0) { throw "pre-tool-use.ps1 exited with code $($p.ExitCode)" } + + $statusFile = Join-Path $env:APPDATA 'mcode-island\status.json' + if (-not (Test-Path $statusFile)) { throw "hook did not write $statusFile" } + $status = Get-Content $statusFile -Raw | ConvertFrom-Json + if ($status.state -ne 'working') { throw "status.state: got '$($status.state)' (want 'working')" } + if ($status.source -ne 'agent') { throw "status.source: got '$($status.source)' (want 'agent' -- hook push is agent-sourced)" } + if ($status.message -notmatch '^Bash\s*:') { throw "status.message: got '$($status.message)' (want to start with 'Bash :')" } + if ($status.message -notmatch 'ci-pretooluse-test') { + throw "status.message: got '$($status.message)' (want to contain 'ci-pretooluse-test')" + } + Write-Host "Hook PreToolUse OK: state=$($status.state) source=$($status.source) message='$($status.message)'" + + # 4) Mocked usage-API behavior. mcode-status-detect.ps1's + # Get-5hUsage function constructs the URL via the + # byte-array `_s` helper, reads the bearer token from + # $env:MINIMAX_OAUTH_TOKEN (or config.json planApiToken), + # and calls Invoke-RestMethod against api.minimaxi.com. + # The detector's main loop is not exercised (it would + # block for 60s+ and require a real mcode install); we + # instead exercise the exact same `(url, headers, token)` + # triple in this step. A Start-Job starts an + # HttpListener on a free 127.0.0.1 port and sync-waits + # for one request; the job records the Authorization + # header, returns a synthetic model_remains JSON, and + # returns the captured header + path via Receive-Job. + - name: Mocked usage-API roundtrip via local HttpListener (round-5 requirement #3) + env: + FAKE_TOKEN: 'ci-fake-oauth-token-1234567890abcdef' + run: | + # Pick a free port before starting the listener, so the + # Invoke-RestMethod in the main step can use it. + $probe = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0) + $probe.Start() + $freePort = [int]$probe.LocalEndpoint.Port + $probe.Stop() + Write-Host "Picked free port: $freePort" + + # Background job: start HttpListener, sync-wait one + # request, return the captured header + path. + $job = Start-Job -ScriptBlock { + param($port) + $listener = [System.Net.HttpListener]::new() + $listener.Prefixes.Add("http://127.0.0.1:$port/") + $listener.Start() + try { + $ctx = $listener.GetContext() # blocks until a request arrives + $auth = $ctx.Request.Headers['Authorization'] + $path = $ctx.Request.Url.AbsolutePath + $body = '{"model_remains":[{"model":"general","remainingPct":84,"resetMs":16200000}]}' + $bytes = [System.Text.Encoding]::UTF8.GetBytes($body) + $ctx.Response.StatusCode = 200 + $ctx.Response.ContentType = 'application/json' + $ctx.Response.ContentLength64 = $bytes.Length + $ctx.Response.OutputStream.Write($bytes, 0, $bytes.Length) + $ctx.Response.Close() + [PSCustomObject]@{ auth = $auth; path = $path } + } finally { + $listener.Stop() + $listener.Close() + } + } -ArgumentList $freePort + + try { + # 4a) Token resolution: env wins over config.json. + # Write a different token to config.json (the + # fallback path); the env var must still win. + $env:MINIMAX_OAUTH_TOKEN = $env:FAKE_TOKEN + $cfgDir = Join-Path $env:APPDATA 'mcode-island' + if (-not (Test-Path $cfgDir)) { New-Item -ItemType Directory -Path $cfgDir -Force | Out-Null } + @{ planApiToken = 'config-token-should-not-be-used' } | ConvertTo-Json | + Out-File -FilePath (Join-Path $cfgDir 'config.json') -Encoding utf8 + + # 4b) Reconstruct the URL the detector uses (the + # source file constructs it via the byte-array + # `_s` helper, which is private to + # mcode-status-detect.ps1; we don't want to + # dot-source the file because the main loop + # would block in CI). The literal path the + # detector requests is /v1/coding_plan/remains. + $url = "http://127.0.0.1:$freePort/v1/coding_plan/remains" + $headers = @{ + 'Authorization' = "Bearer $env:MINIMAX_OAUTH_TOKEN" + 'MM-API-Source' = 'MiniMax-MCP' + } + $resp = Invoke-RestMethod -Uri $url -Headers $headers -TimeoutSec 10 -Method Get -ErrorAction Stop + + # 4c) The mock must have seen the bearer token AND + # the request path the detector uses. + $mock = $job | Wait-Job -Timeout 15 | Receive-Job + if (-not $mock) { + throw "listener job did not complete within 15s (state: $($job.State))" + } + if ($mock.auth -ne "Bearer $env:FAKE_TOKEN") { + throw "mock saw Authorization='$($mock.auth)' (want 'Bearer $env:FAKE_TOKEN')" + } + if ($mock.path -ne '/v1/coding_plan/remains') { + throw "mock saw path='$($mock.path)' (want '/v1/coding_plan/remains')" + } + + # 4d) Response shape: same one Get-5hUsage in + # mcode-status-detect.ps1 parses (model_remains[], + # taking the first entry's remainingPct + resetMs). + if (-not $resp -or -not $resp.model_remains) { + throw "response shape: missing model_remains, got: $($resp | ConvertTo-Json -Compress)" + } + $first = @($resp.model_remains)[0] + if ($first.remainingPct -ne 84 -or $first.resetMs -ne 16200000) { + throw "first model_remains entry: got remainingPct=$($first.remainingPct) resetMs=$($first.resetMs) (want 84 / 16200000)" + } + Write-Host "Mocked usage-API OK: auth='$($mock.auth)' path='$($mock.path)' first entry=remainingPct=$($first.remainingPct)% resetMs=$($first.resetMs)" + } finally { + # Make sure the job is fully reaped even on + # exception, so the listener is released. + if ($job.State -ne 'Completed') { Stop-Job $job } + Remove-Job $job -Force + } diff --git a/plugins/antianqi/mcode-island/.gitattributes b/plugins/antianqi/mcode-island/.gitattributes new file mode 100644 index 0000000..8140744 --- /dev/null +++ b/plugins/antianqi/mcode-island/.gitattributes @@ -0,0 +1,24 @@ +# 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. +# +# Override at clone time: `git config core.autocrlf input` for a +# one-shot pull, or set `[core] autocrlf = false` globally. + +* text=auto eol=lf + +*.ps1 text eol=lf +*.cmd text eol=lf +*.mjs text eol=lf +*.js text eol=lf +*.json text eol=lf +*.md text eol=lf +*.txt text eol=lf +LICENSE text eol=lf +README.md text eol=lf + +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary diff --git a/plugins/antianqi/mcode-island/README.md b/plugins/antianqi/mcode-island/README.md index 3b9705c..0f39645 100644 --- a/plugins/antianqi/mcode-island/README.md +++ b/plugins/antianqi/mcode-island/README.md @@ -23,6 +23,73 @@ 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. +## How the pill is driven + +`mcode-island` v0.3.0 supports two modes. The widget behaves the same in +both — what changes is who decides the state. + +### Mode A — Hook-driven (mcode 0.2.4+ with `io.minimax.mcode`) + +mcode 0.2.4 ships a `io.minimax.mcode` client-extension namespace for +lifecycle Hooks. When the registry accepts it (companion proposal: +[`MiniMax-Code-Plugins` PR #20](https://github.com/MiniMax-AI/MiniMax-Code-Plugins/pull/20)), +the runtime spawns a script from this plugin for every matching event: + +| event | pill state | script | 0.2.4 dispatch | +| ----------------- | ----------- | ------------------------------- | -------------- | +| `SessionStart` | `idle` | `session-start.ps1` | yes | +| `SessionEnd` | `idle` | `session-end.ps1` | yes | +| `UserPromptSubmit`| `thinking` | `user-prompt-submit.ps1` | yes | +| `PreToolUse` | `working` | `pre-tool-use.ps1` | yes | +| `PostToolUse` | `done`/`error` | `post-tool-use.ps1` | yes | +| `Stop` | `done` | `stop.ps1` | **forward** — see below | +| `PreCompact` | `thinking` | `pre-compact.ps1` | **forward** — see below | +| `Notification` | `idle` | `notification.ps1` | **forward** — see below | +| `SubagentStart` | `working` (CODEX only) | `subagent-start.ps1` | **forward** — see below | +| `SubagentStop` | `done` (CODEX only) | `subagent-stop.ps1` | **forward** — see below | +| `PermissionRequest`| `waiting` | `permission-request.ps1` | **forward** — see below | +| `PermissionDenied`| `error` | `permission-denied.ps1` | **forward** — see below | + +**Forward events (7 of 12):** the spec reserves these in +`proposals/hooks-detailed-spec.md` and this plugin ships a script for +each, but the mcode 0.2.4 runtime allowlist (`Wso` set in +`@minimax-ai/code@0.2.4`) does not yet dispatch them. The 0.2.4 +runtime treats unknown event names as no-op. Once a future mcode +release adds the dispatch, the same `.ps1` files start firing without +any code change here. The smoke test +(`scripts/smoke.mjs`) tags these as `WARN` rather than `FAIL` for that +reason — the **plugin is correct, the runtime is not yet ready**. + +If you need any of these events on 0.2.4 today, the supported fallback +is to call `notify-island.ps1` from the agent (Mode B) at the moment +you would otherwise rely on the event firing. The wrapper +`wrap-tool.ps1` covers the `Bash` path automatically. + +The agent does not need to remember to push state — the runtime fires the +right script at the right time. `PermissionRequest` is the only +decision-bearing event here; the script returns `{"decision":"ask"}` so the +plugin remains a pure observer (it does not auto-allow or auto-deny). +The runtime's fail-closed default is bypassed only because the script +opts the Hook into the "ask the user" path, so the TUI prompt still +appears and the user can approve or deny. The widget just shows +`waiting` so the user knows to act. + +> **Drift lock**: `scripts/smoke.mjs` reads `permission-request.ps1` +> directly and asserts the `decision` field is exactly `ask`. A +> future change that flips the value back to `allow` or `deny` will +> fail the smoke before the PR can be submitted. + +Until the registry validator accepts the namespace, the `io.minimax.mcode/` +directory is dormant and the plugin falls through to Mode B. + +### Mode B — Agent-pushed (legacy, always works) + +The agent (or a thin wrapper) calls `notify-island.ps1` with `-State` and +optional `-Message`. A separate `mcode-status-detect.ps1` polls the runtime's +`ledger.jsonl` / `messages.jsonl` and infers state as a fallback so the +pill still moves even when the agent forgets to push. See +[`SKILL.md`](skills/mcode-island/SKILL.md) for the agent-side call patterns. + ## Copyable example ### One-line install and run @@ -118,21 +185,39 @@ alternative: ``` 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 +├── 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 (Mode B) +├── wrap-tool.ps1 # all-in-one bash wrapper +├── mcode-status-detect.ps1 # runtime-state detector (Mode B fallback) +├── io.minimax.mcode/ # Mode A: client-extension Hooks +│ └── hooks/ +│ ├── hooks.json # 12-event declaration +│ └── scripts/ # one .ps1 per event +│ ├── _lib.ps1 +│ ├── session-start.ps1 +│ ├── session-end.ps1 +│ ├── user-prompt-submit.ps1 +│ ├── pre-tool-use.ps1 +│ ├── post-tool-use.ps1 +│ ├── stop.ps1 +│ ├── pre-compact.ps1 +│ ├── notification.ps1 +│ ├── subagent-start.ps1 +│ ├── subagent-stop.ps1 +│ ├── permission-request.ps1 +│ └── permission-denied.ps1 +├── 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 @@ -145,10 +230,11 @@ binary, no symlink, no `node_modules`. | 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) | +| mcode | any version (Mode B works everywhere); 0.2.4+ activates Mode A | | execution policy | `Bypass` for this directory; not changed globally | -| network access | **none** — widget does not make any network request | -| accounts | **none** | -| paid services | **none** | +| 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"). | +| 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 | ## Data use @@ -157,13 +243,59 @@ binary, no symlink, no `node_modules`. | `status.json` | `%APPDATA%\mcode-island\` | rewritten every transition | widget polling | | `caller.json` | `%APPDATA%\mcode-island\` | rewritten every transition | click-to-focus target HWND | | `config.json` | `%APPDATA%\mcode-island\` | rewritten on drag | pill position, size, opacity | +| `config.json` -> `planApiToken` | `%APPDATA%\mcode-island\` | until `-Clear` or manual edit | 5h usage API token (opt-in; see "Accounts") | | `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.** +**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\`. + +## 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: + +1. A token is configured (env `MINIMAX_OAUTH_TOKEN` or `MINIMAX_API_KEY`, + or `set-token.ps1 ` which writes to `config.json:planApiToken`). +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 + the URL, no fragment, body is a small JSON object) + +The response is parsed and only two numbers are written to +`status.json`: `usage5h` (0..100, percent remaining) and `usage5hResetMs` +(milliseconds until the next refresh). Nothing else is persisted and +nothing is sent back to the plugin author. A failure or timeout is +swallowed silently — the pill still works without the readout. + +Without a token, the detector skips this call entirely and the pill's +`usage5h` field is `null`. + +## Accounts + +No account is required to install or use the widget. The token mechanism +exists so users who already have a MiniMax account can opt in to showing +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 | + +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 +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. ## CLI reference @@ -222,12 +354,18 @@ a live MiniMax Code session. Empirical evidence (captured during development): - 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. + `notify-island.ps1` calls. (In Mode A, all tools fire `PreToolUse` / + `PostToolUse` automatically — no manual push needed.) - 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. +- Mode A (Hook-driven) requires the registry validator to accept the + `io.minimax.mcode` client-extension namespace. The companion proposal + ([`MiniMax-Code-Plugins` PR #20](https://github.com/MiniMax-AI/MiniMax-Code-Plugins/pull/20)) + is still pending merge; until then, the `io.minimax.mcode/hooks/` directory + is dormant and the widget runs in Mode B (agent-pushed + detector). ## Roadmap diff --git a/plugins/antianqi/mcode-island/autostart.ps1 b/plugins/antianqi/mcode-island/autostart.ps1 index 51ed2dd..17b1a9a 100644 --- a/plugins/antianqi/mcode-island/autostart.ps1 +++ b/plugins/antianqi/mcode-island/autostart.ps1 @@ -1,8 +1,14 @@ # mcode-island - 开机自启管理 # 用法: -# autostart.ps1 -Enable # 注册到 HKCU\...\Run,开机自动起 -# autostart.ps1 -Disable # 取消 +# autostart.ps1 -Enable # 注册 widget + detector 两个 Run 项,开机自动起 +# autostart.ps1 -Disable # 取消全部 # autostart.ps1 -Status # 看当前状态 +# +# 设计:以前只注册 widget(start-island.ps1),detector 不会自启——结果用户开机后 +# widget 卡在最后一次推送的状态上,得手动跑 detect-on。修成两条独立的 Run key: +# HKCU\...\Run\mcode-island → start-island.ps1 +# HKCU\...\Run\mcode-island-detect → start-detect-island.ps1 +# 两条相互独立,可以单独禁用其中之一(比如有人只想用 widget 不想用 detector)。 param( [ValidateSet('Enable','Disable','Status')] @@ -13,33 +19,50 @@ $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`"" + +# 用 ordered hashtable 固定顺序:先 detector 再 widget(Windows 实际不保证顺序,但人读起来顺眼) +$entries = [ordered]@{ + 'mcode-island-detect' = (Join-Path $PSScriptRoot 'start-detect-island.ps1') + 'mcode-island' = (Join-Path $PSScriptRoot 'start-island.ps1') +} + +function Build-Command([string]$Launcher) { + return "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" + foreach ($name in $entries.Keys) { + $cmd = Build-Command $entries[$name] + Set-ItemProperty -Path $runKey -Name $name -Value $cmd + Write-Output "ENABLED: $runKey\$name" + Write-Output " Value: $cmd" + } } 'Disable' { - if (Get-ItemProperty -Path $runKey -Name $entryName -ErrorAction SilentlyContinue) { - Remove-ItemProperty -Path $runKey -Name $entryName - Write-Output 'DISABLED: 开机自启已取消' - } else { - Write-Output 'DISABLED: 本来就没注册' + $any = $false + foreach ($name in $entries.Keys) { + if (Get-ItemProperty -Path $runKey -Name $name -ErrorAction SilentlyContinue) { + Remove-ItemProperty -Path $runKey -Name $name + Write-Output "DISABLED: $name" + $any = $true + } } + if (-not $any) { 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' + $any = $false + foreach ($name in $entries.Keys) { + $existing = Get-ItemProperty -Path $runKey -Name $name -ErrorAction SilentlyContinue + if ($existing) { + Write-Output "ENABLED: $name" + Write-Output " Value: $($existing.$name)" + $any = $true + } else { + Write-Output "DISABLED: $name" + } } + if (-not $any) { Write-Output '' ; Write-Output '(no mcode-island entries registered)' } } } diff --git a/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/hooks.json b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/hooks.json new file mode 100644 index 0000000..08ee02e --- /dev/null +++ b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/hooks.json @@ -0,0 +1,163 @@ +{ + "$schema": "https://minimax.io/schemas/mcode-hooks/0.1.0/hooks.schema.json", + "hooks": { + "SessionStart": [ + { + "command": "powershell", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "${PLUGIN_ROOT}/io.minimax.mcode/hooks/scripts/session-start.ps1" + ], + "timeout": 5000 + } + ], + "SessionEnd": [ + { + "command": "powershell", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "${PLUGIN_ROOT}/io.minimax.mcode/hooks/scripts/session-end.ps1" + ], + "timeout": 5000 + } + ], + "UserPromptSubmit": [ + { + "command": "powershell", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "${PLUGIN_ROOT}/io.minimax.mcode/hooks/scripts/user-prompt-submit.ps1" + ], + "timeout": 5000 + } + ], + "PreToolUse": [ + { + "matcher": "*", + "command": "powershell", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "${PLUGIN_ROOT}/io.minimax.mcode/hooks/scripts/pre-tool-use.ps1" + ], + "timeout": 5000 + } + ], + "PostToolUse": [ + { + "matcher": "*", + "command": "powershell", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "${PLUGIN_ROOT}/io.minimax.mcode/hooks/scripts/post-tool-use.ps1" + ], + "timeout": 5000 + } + ], + "Stop": [ + { + "command": "powershell", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "${PLUGIN_ROOT}/io.minimax.mcode/hooks/scripts/stop.ps1" + ], + "timeout": 5000 + } + ], + "PreCompact": [ + { + "command": "powershell", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "${PLUGIN_ROOT}/io.minimax.mcode/hooks/scripts/pre-compact.ps1" + ], + "timeout": 5000 + } + ], + "Notification": [ + { + "command": "powershell", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "${PLUGIN_ROOT}/io.minimax.mcode/hooks/scripts/notification.ps1" + ], + "timeout": 5000 + } + ], + "SubagentStart": [ + { + "command": "powershell", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "${PLUGIN_ROOT}/io.minimax.mcode/hooks/scripts/subagent-start.ps1" + ], + "timeout": 5000 + } + ], + "SubagentStop": [ + { + "command": "powershell", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "${PLUGIN_ROOT}/io.minimax.mcode/hooks/scripts/subagent-stop.ps1" + ], + "timeout": 5000 + } + ], + "PermissionRequest": [ + { + "command": "powershell", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "${PLUGIN_ROOT}/io.minimax.mcode/hooks/scripts/permission-request.ps1" + ], + "timeout": 5000 + } + ], + "PermissionDenied": [ + { + "command": "powershell", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "${PLUGIN_ROOT}/io.minimax.mcode/hooks/scripts/permission-denied.ps1" + ], + "timeout": 5000 + } + ] + } +} 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 new file mode 100644 index 0000000..797813b --- /dev/null +++ b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/_lib.ps1 @@ -0,0 +1,110 @@ +# 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 +# reason) — never throw, never block the agent loop on a notification push. + +$ErrorActionPreference = 'Stop' + +# Resolve the plugin root and the canonical IPC helper. The hook scripts +# live at /io.minimax.mcode/hooks/scripts/.ps1, so +# $PSScriptRoot\..\..\.. is the plugin root. +$script:PluginRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..\..')).Path +$script:NotifyIsland = Join-Path $script:PluginRoot 'notify-island.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, + # but doing it here avoids any risk of mojibake in our own logs. + try { + [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + $OutputEncoding = [System.Text.Encoding]::UTF8 + } catch {} +} + +function Read-HookStdin { + # mcode delivers the hook event as a JSON object on stdin. + # Some events arrive with empty stdin (notably SessionEnd on + # hard-terminate); in that case return $null and let the caller + # decide what to do. + try { + $raw = [Console]::In.ReadToEnd() + if ([string]::IsNullOrWhiteSpace($raw)) { return $null } + return ($raw | ConvertFrom-Json -ErrorAction Stop) + } catch { + return $null + } +} + +function Push-Island { + # Thin wrapper over the canonical IPC. Never throws. + param( + [Parameter(Mandatory)] + [ValidateSet('idle','thinking','working','waiting','done','error')] + [string]$State, + + [string]$Message = '' + ) + if (-not (Test-Path -LiteralPath $script:NotifyIsland)) { + # Widget is not installed yet — silent no-op. The plugin's + # CLI still has to be runnable on machines where the widget + # was not started. + return + } + try { + & $script:NotifyIsland -State $State -Message $Message 2>$null | Out-Null + } catch { + # Hook must never block the agent on a notification failure. + } +} + +function Test-IsSelfPush { + # The hook for Pre/PostToolUse fires for every Bash invocation, + # including the agent's own notify-island.ps1 / wrap-tool.ps1 + # pushes. Pushing `working: bash: notify-island.ps1` immediately + # followed by the agent's own push of `error: ...` would be + # misleading on the pill. Filter our own internal calls. + param($Event) + if ($null -eq $Event) { return $false } + if ($Event.tool_name -ne 'Bash') { return $false } + + $cmd = '' + if ($Event.tool_input) { + if ($Event.tool_input.command) { $cmd = [string]$Event.tool_input.command } + elseif ($Event.tool_input.cmd) { $cmd = [string]$Event.tool_input.cmd } + } + if ([string]::IsNullOrEmpty($cmd)) { return $false } + + return ($cmd -match 'notify-island\.ps1|wrap-tool\.ps1|island\\notify|island\\wrap') +} + +function Format-ToolSummary { + # Compact ": " used in pill messages. + # Truncated to keep the WPF label single-line. + param($Event) + $tool = if ($Event.tool_name) { [string]$Event.tool_name } else { 'tool' } + $detail = '' + + if ($Event.tool_input) { + switch ($tool) { + 'Bash' { $detail = [string]$Event.tool_input.command } + 'Read' { $detail = [string]$Event.tool_input.file_path } + 'Write' { $detail = [string]$Event.tool_input.file_path } + 'Edit' { $detail = [string]$Event.tool_input.file_path } + 'Glob' { $detail = [string]$Event.tool_input.pattern } + 'Grep' { $detail = [string]$Event.tool_input.pattern } + 'WebFetch' { $detail = [string]$Event.tool_input.url } + 'WebSearch' { $detail = [string]$Event.tool_input.query } + 'Task' { $detail = [string]$Event.tool_input.description } + 'NotebookEdit' { $detail = [string]$Event.tool_input.notebook_path } + default { $detail = '' } + } + } + if ([string]::IsNullOrEmpty($detail)) { return $tool } + # Collapse newlines, take first 80 chars. + $detail = ($detail -replace "[\r\n]+", ' ').Trim() + if ($detail.Length -gt 80) { $detail = $detail.Substring(0, 77) + '...' } + return "$tool : $detail" +} + +Set-ConsoleUtf8 diff --git a/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/notification.ps1 b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/notification.ps1 new file mode 100644 index 0000000..2ffdad1 --- /dev/null +++ b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/notification.ps1 @@ -0,0 +1,16 @@ +# Hook: Notification +# Event: io.minimax.mcode / Notification +# State: idle +# Note: Fires when the runtime emits a system notification (e.g. +# "session timed out", "rate limited"). We push idle rather +# than working/error because a notification is a passive +# informational event, not an agent action. The notification +# text is surfaced in the pill so the user can read it. +. "$PSScriptRoot\_lib.ps1" +$evt = Read-HookStdin +$text = '' +if ($evt.message) { $text = [string]$evt.message } +elseif ($evt.notification) { $text = [string]$evt.notification } +if ($text.Length -gt 80) { $text = $text.Substring(0, 77) + '...' } +Push-Island -State idle -Message $text +exit 0 diff --git a/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/permission-denied.ps1 b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/permission-denied.ps1 new file mode 100644 index 0000000..6e2bdf7 --- /dev/null +++ b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/permission-denied.ps1 @@ -0,0 +1,11 @@ +# Hook: PermissionDenied +# Event: io.minimax.mcode / PermissionDenied +# State: error +# Note: Fires after a permission has been denied (rare in 0.2.4 +# per the spec; treat as advisory). We push error so the +# user sees the pill turn red and knows to investigate. +. "$PSScriptRoot\_lib.ps1" +$evt = Read-HookStdin +$tool = if ($evt.tool_name) { [string]$evt.tool_name } else { 'permission' } +Push-Island -State error -Message "$tool denied" +exit 0 diff --git a/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/permission-request.ps1 b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/permission-request.ps1 new file mode 100644 index 0000000..f2dc37b --- /dev/null +++ b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/permission-request.ps1 @@ -0,0 +1,25 @@ +# Hook: PermissionRequest +# Event: io.minimax.mcode / PermissionRequest +# State: waiting +# Decision: ask +# Note: This is a DECISION-BEARING event. Per the io.minimax.mcode +# Hooks spec (MiniMax-Code-Plugins PR #20, section "Decision +# semantics"), an observer Hook on PermissionRequest MUST return +# `ask` (or no decision at all) and MUST NOT return `allow` or +# `deny` unless the Plugin is genuinely the permission owner. +# +# The 0.2.4 Runtime default for PermissionRequest is fail-closed +# (`deny`), which would make a pure observer indistinguishable +# from a denial and break the portable observe-only floor. +# Returning `ask` opts the Hook out of fail-closed: the pill +# surfaces the waiting state, the user still sees the TUI +# prompt, and the runtime's Permission Core remains the +# permission owner. The user can still approve or deny. +. "$PSScriptRoot\_lib.ps1" +$evt = Read-HookStdin +$tool = if ($evt.tool_name) { [string]$evt.tool_name } else { 'permission' } +Push-Island -State waiting -Message "$tool needs approval" +# Observer opt-in decision. Written to stdout in the shape the +# io.minimax.mcode spec defines for PermissionRequest. Exit 0 = OK. +[Console]::Out.WriteLine('{"decision":"ask","reason":"island-only observer; permission owner remains runtime Permission Core"}') +exit 0 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 new file mode 100644 index 0000000..034a31e --- /dev/null +++ b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/post-tool-use.ps1 @@ -0,0 +1,28 @@ +# 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. +. "$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 + +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 } +} + +if ($isError) { + Push-Island -State error -Message "$tool failed" +} else { + Push-Island -State done -Message "$tool ok" +} +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 new file mode 100644 index 0000000..8e4460f --- /dev/null +++ b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/pre-compact.ps1 @@ -0,0 +1,13 @@ +# Hook: PreCompact +# Event: io.minimax.mcode / PreCompact +# State: thinking +# Note: Fires before the runtime compresses context. We push +# thinking so the pill signals "agent is still doing +# something" — without this, the pill might sit in `done` +# while the model is mid-compaction and the user wonders +# whether the agent is alive. +. "$PSScriptRoot\_lib.ps1" +$evt = Read-HookStdin +$trigger = if ($evt.trigger) { [string]$evt.trigger } else { 'context' } +Push-Island -State thinking -Message "compacting ($trigger)" +exit 0 diff --git a/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/pre-tool-use.ps1 b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/pre-tool-use.ps1 new file mode 100644 index 0000000..cb143a8 --- /dev/null +++ b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/pre-tool-use.ps1 @@ -0,0 +1,12 @@ +# Hook: PreToolUse +# Event: io.minimax.mcode / PreToolUse +# State: working +# Note: Fires before every tool call. We push working with a short +# tool-name + input summary. Self-push calls (the agent's +# own notify-island / wrap-tool invocations through Bash) are +# filtered to avoid recursive state churn. +. "$PSScriptRoot\_lib.ps1" +$evt = Read-HookStdin +if (Test-IsSelfPush $evt) { exit 0 } +Push-Island -State working -Message (Format-ToolSummary $evt) +exit 0 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 new file mode 100644 index 0000000..214f562 --- /dev/null +++ b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/session-end.ps1 @@ -0,0 +1,9 @@ +# Hook: SessionEnd +# Event: io.minimax.mcode / SessionEnd +# State: idle +# Note: Fires when the runtime terminates a session. We push idle +# so the pill returns to a known resting color. The widget +# itself stays alive — only its state is reset. +. "$PSScriptRoot\_lib.ps1" +Push-Island -State idle -Message "session ended" +exit 0 diff --git a/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/session-start.ps1 b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/session-start.ps1 new file mode 100644 index 0000000..2236654 --- /dev/null +++ b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/session-start.ps1 @@ -0,0 +1,11 @@ +# Hook: SessionStart +# Event: io.minimax.mcode / SessionStart +# State: idle +# Note: Fires when the runtime starts a session. We push idle to +# confirm the pill is alive; the widget may have been started +# before the session was open. +. "$PSScriptRoot\_lib.ps1" +$evt = Read-HookStdin +$sid = if ($evt.session_id) { $evt.session_id.Substring(0, 8) } else { '?' } +Push-Island -State idle -Message "session $sid" +exit 0 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 new file mode 100644 index 0000000..0637c43 --- /dev/null +++ b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/stop.ps1 @@ -0,0 +1,12 @@ +# Hook: Stop +# Event: io.minimax.mcode / Stop +# State: done +# Note: Fires when the agent finishes a turn (one model response, +# any number of tool calls). This is the natural "this turn +# is done" signal — the pill goes green until the next +# UserPromptSubmit turns it yellow again. +. "$PSScriptRoot\_lib.ps1" +$evt = Read-HookStdin +$reason = if ($evt.stop_reason) { [string]$evt.stop_reason } else { 'turn complete' } +Push-Island -State done -Message $reason +exit 0 diff --git a/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/subagent-start.ps1 b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/subagent-start.ps1 new file mode 100644 index 0000000..efbbd14 --- /dev/null +++ b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/subagent-start.ps1 @@ -0,0 +1,16 @@ +# Hook: SubagentStart +# Event: io.minimax.mcode / SubagentStart +# State: working +# Note: Fires when the agent delegates a subtask to a subagent. +# Bridged only on the CODEX native client surface; no +# deliveries on CLAUDE. We push working so the pill +# reflects the visible "the agent is still busy" state +# even though the work is happening in a child context. +. "$PSScriptRoot\_lib.ps1" +$evt = Read-HookStdin +$name = if ($evt.subagent_type) { [string]$evt.subagent_type } else { 'subagent' } +$desc = if ($evt.description) { [string]$evt.description } else { '' } +if ($desc.Length -gt 60) { $desc = $desc.Substring(0, 57) + '...' } +$msg = if ($desc) { "delegate: $name - $desc" } else { "delegate: $name" } +Push-Island -State working -Message $msg +exit 0 diff --git a/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/subagent-stop.ps1 b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/subagent-stop.ps1 new file mode 100644 index 0000000..00e64f2 --- /dev/null +++ b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/subagent-stop.ps1 @@ -0,0 +1,11 @@ +# Hook: SubagentStop +# Event: io.minimax.mcode / SubagentStop +# State: done +# Note: Fires when a delegated subagent finishes. We push done; +# the pill goes green. If the main agent subsequently calls +# another tool, PreToolUse will turn it back to working. +. "$PSScriptRoot\_lib.ps1" +$evt = Read-HookStdin +$name = if ($evt.subagent_type) { [string]$evt.subagent_type } else { 'subagent' } +Push-Island -State done -Message "$name returned" +exit 0 diff --git a/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/user-prompt-submit.ps1 b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/user-prompt-submit.ps1 new file mode 100644 index 0000000..ba39a41 --- /dev/null +++ b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/user-prompt-submit.ps1 @@ -0,0 +1,10 @@ +# Hook: UserPromptSubmit +# Event: io.minimax.mcode / UserPromptSubmit +# State: thinking +# Note: Fires right after the user presses Enter on a new turn, +# before the agent starts reasoning. Pushing thinking here +# avoids the gap where the pill would otherwise sit in idle +# (yellow pulse = "I heard you, working on it"). +. "$PSScriptRoot\_lib.ps1" +Push-Island -State thinking -Message "reasoning" +exit 0 diff --git a/plugins/antianqi/mcode-island/mcode-island.cmd b/plugins/antianqi/mcode-island/mcode-island.cmd index e817ce0..2720cda 100644 --- a/plugins/antianqi/mcode-island/mcode-island.cmd +++ b/plugins/antianqi/mcode-island/mcode-island.cmd @@ -14,8 +14,12 @@ 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 +if /i "%1"=="detect-on" goto :detect_on +if /i "%1"=="detect-off" goto :detect_off +if /i "%1"=="detect-status" goto :detect_status +if /i "%1"=="set-token" goto :set_token -echo Usage: mcode-island {start ^| stop ^| status ^| show ^| hide ^| pin ^| unpin ^| autostart-on ^| autostart-off} +echo Usage: mcode-island {start ^| stop ^| status ^| show ^| hide ^| pin ^| unpin ^| autostart-on ^| autostart-off ^| detect-on ^| detect-off ^| detect-status ^| set-token} exit /b 1 :start @@ -53,3 +57,28 @@ exit /b %errorlevel% :autostart_off %PS% -File "%SCRIPT_DIR%autostart.ps1" -Action Disable exit /b %errorlevel% + +:detect_on +%PS% -File "%SCRIPT_DIR%start-detect-island.ps1" +exit /b %errorlevel% + +:detect_off +%PS% -File "%SCRIPT_DIR%stop-detect-island.ps1" +exit /b %errorlevel% + +:detect_status +%PS% -File "%SCRIPT_DIR%status-detect-island.ps1" +exit /b %errorlevel% + +:set_token +if "%2"=="" goto :set_token_show +if /i "%2"=="-show" goto :set_token_show +if /i "%2"=="-clear" goto :set_token_clear +%PS% -File "%SCRIPT_DIR%set-token.ps1" "%2" +exit /b %errorlevel% +:set_token_show +%PS% -File "%SCRIPT_DIR%set-token.ps1" -Show +exit /b %errorlevel% +:set_token_clear +%PS% -File "%SCRIPT_DIR%set-token.ps1" -Clear +exit /b %errorlevel% diff --git a/plugins/antianqi/mcode-island/mcode-island.ps1 b/plugins/antianqi/mcode-island/mcode-island.ps1 index cd4334f..898322a 100644 --- a/plugins/antianqi/mcode-island/mcode-island.ps1 +++ b/plugins/antianqi/mcode-island/mcode-island.ps1 @@ -16,10 +16,32 @@ Dbg "PID=$PID APART=$([System.Threading.Thread]::CurrentThread.ApartmentState)" 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 + $psi2 = New-Object System.Diagnostics.ProcessStartInfo + $psi2.FileName = 'powershell.exe' + $psi2.Arguments = $args2 -join ' ' + $psi2.UseShellExecute = $false + $psi2.CreateNoWindow = $true + [void][System.Diagnostics.Process]::Start($psi2) exit } +# 防御:万一 Start-Process 那层漏了控制台窗口,进来第一件事就藏掉。 +# GetConsoleWindow() 在没有控制台时返回 0,ShowWindow 直接 no-op。 +Dbg 'hiding any stray console window' +$hideSig = @' +using System; +using System.Runtime.InteropServices; +public class IslandHide { + [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); + [DllImport("kernel32.dll")] public static extern IntPtr GetConsoleWindow(); +} +'@ +if (-not ('IslandHide' -as [type])) { Add-Type $hideSig -ErrorAction SilentlyContinue } +$hwnd = [IslandHide]::GetConsoleWindow() +if ($hwnd -ne [IntPtr]::Zero) { + [void][IslandHide]::ShowWindow($hwnd, 0) # SW_HIDE +} + # 加载 WPF Dbg 'loading WPF assemblies' try { @@ -84,7 +106,7 @@ $defaultConfig = [PSCustomObject]@{ x = -1 y = -1 width = 320 - height = 60 + height = 70 opacity = 0.95 autostart = $false } @@ -112,7 +134,7 @@ $xaml = @' Focusable="False" ShowActivated="False"> + Padding="22,6" Margin="0"> @@ -136,11 +158,59 @@ $xaml = @' - + + + + + + + + + + + + + + + + + + + + 0,0.5 + + + + + + + + + + + + + + + + TodoProgress > shimmer +# - 即:agent 直接传 progress 最高;否则如果有 todo 列表就用 todo 完成度;都没就 shimmer 动画 function Update-State { - param([string]$State, [string]$Message) + param( + [string]$State, + [string]$Message, + [int]$Progress = -1, + [int]$Usage5h = -2, + [int]$Usage5hResetMs = 0, + [int]$TodoProgress = -2 + ) $s = $script:stateMap[$State] if (!$s) { $s = $script:stateMap['idle'] } $script:statusDot.Fill = C $s.dot @@ -241,9 +398,83 @@ function Update-State { if ($State -in @('thinking','working','waiting')) { Start-Pulse } else { Stop-Pulse } + # 剩余用量:时间 + 剩余 % 拼一起(如 "4h31m 84%"),颜色按"剩余百分比"走 + # 剩余 < 20% 红,20-50% 黄,>= 50% 灰 + $hasTime = $Usage5hResetMs -gt 0 + $hasPct = $Usage5h -ge 0 -and $Usage5h -le 100 + if ($hasTime -or $hasPct) { + $parts = @() + if ($hasTime) { $parts += (Format-ResetMs $Usage5hResetMs) } + if ($hasPct) { $parts += ("{0}%" -f [int]$Usage5h) } + $script:usage5hText.Text = $parts -join ' ' + $col = if ($Usage5h -ge 50) { '#FF6B7280' } # 剩 >= 50% 灰 + elseif ($Usage5h -ge 20) { '#FFEAB308' } # 剩 20-50% 黄 + else { '#FFEF4444' } # 剩 < 20% 红 + $script:usage5hText.Foreground = C $col + } else { + $script:usage5hText.Text = '' + $script:usage5hText.Foreground = C '#FF6B7280' + } + + # Elapsed timer:进入 active 启动/跨 state 重置,退出 active 停表并清空文字 + $isActive = $State -in @('thinking','working','waiting') + if ($isActive) { + if ($State -ne $script:elapsedLastState) { + $script:elapsedStopwatch.Restart() + $script:elapsedLastState = $State + } + $script:elapsedActive = $true + $script:elapsedText.Text = Format-Elapsed $script:elapsedStopwatch.Elapsed + } else { + $script:elapsedActive = $false + $script:elapsedStopwatch.Reset() + $script:elapsedLastState = $State + $script:elapsedText.Text = '' + } + + # 进度条:四分支(按优先级) + # - active + 显式 progress 0..100 → determinate 用 Progress + # - active + todoProgress 0..100 → determinate 用 TodoProgress + # - active + 都没有 → indeterminate shimmer + # - 非 active → 隐藏 + $clamped = [Math]::Max(0, [Math]::Min(100, $Progress)) + $todoClamped = [Math]::Max(0, [Math]::Min(100, $TodoProgress)) + $isActive = $State -in @('thinking','working','waiting') + $explicitProgress = ($Progress -ge 0 -and $Progress -le 100) + $hasTodoProgress = ($TodoProgress -ge 0 -and $TodoProgress -le 100) + if ($isActive -and $explicitProgress) { + $script:progressBar.Visibility = 'Visible' + $script:progressFill.Visibility = 'Visible' + $script:progressIndeterminate.Visibility = 'Collapsed' + $script:progressScale.ScaleX = $clamped / 100.0 + $script:progressFill.Background = C $s.dot + 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 + Stop-IndeterminateShimmer + } elseif ($isActive) { + $script:progressBar.Visibility = 'Visible' + $script:progressFill.Visibility = 'Collapsed' + $script:progressIndeterminate.Visibility = 'Visible' + $script:progressShimmer.Fill = C $s.dot + $script:progressScale.ScaleX = 0 + Start-IndeterminateShimmer + } else { + $script:progressBar.Visibility = 'Collapsed' + $script:progressFill.Visibility = 'Collapsed' + $script:progressIndeterminate.Visibility = 'Collapsed' + $script:progressScale.ScaleX = 0 + Stop-IndeterminateShimmer + } + # 写入 log $ts = (Get-Date).ToString('HH:mm:ss') - "[$ts] $State :: $Message" | Add-Content -Path $script:logFile -Encoding UTF8 + $progTag = if ($Progress -ge 0) { " [$Progress%]" } else { '' } + "[$ts] $State :: $Message$progTag" | Add-Content -Path $script:logFile -Encoding UTF8 } # 切回调用方窗口(点击 pill 时调用) @@ -457,25 +688,41 @@ $timer.Add_Tick({ 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)" + # progress 也要进 sig,否则 agent 连续推 working+相同 message+不同 progress 会被去重 + $prog = if ($data.PSObject.Properties['progress']) { [int]$data.progress } else { -1 } + $usage = $null + $resetMs = 0 + $todoP = -2 + 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 ($sig -eq $script:lastStatusSig) { return } $script:lastStatusSig = $sig - Dbg "POLL: $($data.state) :: $($data.message)" - Update-State -State $data.state -Message $data.message + 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 } catch { Dbg "POLL ERR: $($_.Exception.Message)" } }) $timer.Start() -Dbg 'poll timer started' +$script:elapsedTimer.Start() +Dbg 'poll timer + elapsed 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)" + $initProg = if ($init.PSObject.Properties['progress']) { [int]$init.progress } else { -1 } + $initUsage = $null + $initReset = 0 + $initTodo = -2 + 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)" $script:lastStatusMtime = (Get-Item $statusFile).LastWriteTimeUtc.Ticks - Update-State -State $init.state -Message $init.message + Update-State -State $init.state -Message $init.message -Progress $initProg -Usage5h $initUsage -Usage5hResetMs $initReset -TodoProgress $initTodo } catch {} } else { Update-State -State 'idle' -Message '' diff --git a/plugins/antianqi/mcode-island/mcode-status-detect.ps1 b/plugins/antianqi/mcode-island/mcode-status-detect.ps1 index 96d2257..415d02f 100644 --- a/plugins/antianqi/mcode-island/mcode-status-detect.ps1 +++ b/plugins/antianqi/mcode-island/mcode-status-detect.ps1 @@ -40,6 +40,22 @@ $ErrorActionPreference = 'Stop' [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 chcp 65001 | Out-Null +# 防御性隐藏控制台窗口:start-detect 用 CreateNoWindow 启的进程理论上没有控制台, +# 但偶尔有边界场景会冒出空窗口被用户误关。这里 SW_HIDE 一下兜底。 +$hideSig = @' +using System; +using System.Runtime.InteropServices; +public class DetectHide { + [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); + [DllImport("kernel32.dll")] public static extern IntPtr GetConsoleWindow(); +} +'@ +if (-not ('DetectHide' -as [type])) { Add-Type $hideSig -ErrorAction SilentlyContinue } +$hwnd = [DetectHide]::GetConsoleWindow() +if ($hwnd -ne [IntPtr]::Zero) { + [void][DetectHide]::ShowWindow($hwnd, 0) +} + function _s { param([byte[]]$b) [System.Text.Encoding]::UTF8.GetString($b) } # role names @@ -92,6 +108,32 @@ if (!(Test-Path $configDir)) { New-Item -ItemType Directory -Path $configDir -Fo $statusFile = Join-Path $configDir 'status.json' $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 +$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) +$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 +$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 {} +} +$script:plan5hLastCallAt = [DateTime]::MinValue +$script:plan5hRemainingPct = $null # 0..100,剩余百分比(不再是已用!) +$script:plan5hResetMs = $null # 距下次刷新的毫秒数 + +# Todo 进度缓存(widget 进度条用):completed / (total - cancelled) * 100 +$script:plan5hTodoData = $null # @{ percent; currentTodo; completed; total } +$script:plan5hTodoCacheMtime = [DateTime]::MinValue +$script:plan5hLastWrittenTodoPct = $null # 上次写到 status.json 的 todoProgress(用来检测变化) # 解析 mcode 安装根目录(/.minimax-code) function Find-McodeRoot { @@ -349,17 +391,125 @@ function Infer-State($msg) { function Write-Status($state, $message) { $tmp = "$statusFile.tmp" + # usage5h:0..100 表示"剩余"百分比(不是已用!);null = 未知/未拉到 + # usage5hResetMs:距下次刷新的毫秒数;null = 未知 + # todoProgress:0..100 完成百分比(cancelled 不计);null = 无 todo 列表 + $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 } $payload = [PSCustomObject]@{ - state = $state - message = $message - progress = -1 - ts = (Get-Date).ToString($FMT_O) - source = $S_DETECTOR + state = $state + message = $message + 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 } +# 5h 用量:调 minimax /v1/coding_plan/remains,返回 general model 的 {remainingPct, resetMs} +# 无 token / 网络错 / 解析错 → 返回 $null +function Get-5hUsage { + if (-not $script:plan5hToken) { return $null } + try { + # PowerShell 5.1 在某些 Windows 上默认 TLS 1.0;强制 1.2 避免握手失败 + if ([System.Net.ServicePointManager]::SecurityProtocol -notmatch 'Tls12') { + [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12 + } + $url = $PLAN_API_HOST + $PLAN_API_PATH + $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 } + foreach ($m in @($resp.model_remains)) { + if ($m.model_name -eq 'general') { + $remPct = [int]$m.current_interval_remaining_percent + if ($remPct -lt 0) { $remPct = 0 } + if ($remPct -gt 100) { $remPct = 100 } + $resetMs = [int]$m.remains_time + if ($resetMs -lt 0) { $resetMs = 0 } + return @{ remainingPct = $remPct; resetMs = $resetMs } + } + } + return $null + } catch { + Log-Line ("5h usage fetch failed: " + $_.Exception.Message) + return $null + } +} + +# 在主循环里每 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 { + $latest = $script:lastLatestFile + if (-not $latest -or -not (Test-Path $latest)) { return $null } + + $mtime = [System.IO.File]::GetLastWriteTimeUtc($latest) + if ($mtime -eq $script:plan5hTodoCacheMtime -and $null -ne $script:plan5hTodoData) { + return $script:plan5hTodoData + } + $script:plan5hTodoCacheMtime = $mtime + + try { + # 从末尾向前找最近的 toolName=todowrite 且 role=toolResult 的行 + $lines = [System.IO.File]::ReadAllLines($latest, [System.Text.Encoding]::UTF8) + for ($i = $lines.Count - 1; $i -ge 0; $i--) { + $line = $lines[$i] + if ($line.IndexOf('"toolName":"todowrite"', [System.StringComparison]::Ordinal) -lt 0) { continue } + if ($line.IndexOf('"role":"toolResult"', [System.StringComparison]::Ordinal) -lt 0) { continue } + $j = $line | ConvertFrom-Json -ErrorAction SilentlyContinue + if (-not $j -or -not $j.message -or -not $j.message.details -or -not $j.message.details.todos) { continue } + $todos = @($j.message.details.todos) + $total = $todos.Count + $done = 0; $cancelled = 0 + foreach ($t in $todos) { + if ($t.status -eq 'completed') { $done++ } + if ($t.status -eq 'cancelled') { $cancelled++ } + } + $effective = $total - $cancelled + if ($effective -le 0) { + $script:plan5hTodoData = $null + return $null + } + $percent = [int][Math]::Floor(($done * 100) / $effective) + $script:plan5hTodoData = @{ + percent = $percent + completed = $done + total = $total + } + return $script:plan5hTodoData + } + # 走完没找到 = 没 todowrite 调用过 + $script:plan5hTodoData = $null + return $null + } catch { + return $null + } +} + function Read-StatusObj { if (!(Test-Path $statusFile)) { return $null } try { return ([System.IO.File]::ReadAllText($statusFile) | ConvertFrom-Json) } catch { return $null } @@ -453,6 +603,39 @@ try { } } + # 4) 5h 用量:每 60s 刷一次,刷新后若数字变化就写 status.json(让 widget 看到) + $prevPct = $script:plan5hRemainingPct + $prevMs = $script:plan5hResetMs + 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 + Log-Line ("5h usage refreshed: remaining=" + $curPct + "% resetMs=" + $curMs) + } + + # 5) Todo 进度:每次都查(带 mtime 缓存),变化时写 status.json + $prevTodoPct = $script:plan5hLastWrittenTodoPct + $curTodoData = Get-TodoProgress + $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 + $script:plan5hLastWrittenTodoPct = $curTodoPct + if ($null -ne $curTodoData) { + Log-Line ("todo refreshed: " + $curTodoData.completed + "/" + $curTodoData.total + " = " + $curTodoPct + "%") + } else { + Log-Line "todo refreshed: (none)" + } + } + if ($Once) { break } Start-Sleep -Milliseconds 1000 } diff --git a/plugins/antianqi/mcode-island/plugin.json b/plugins/antianqi/mcode-island/plugin.json index f32b034..f39491d 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.2.0", - "description": "Windows 桌面灵动岛 (Dynamic Island) 状态窗口:让 mcode agent 把工作状态(idle/thinking/working/waiting/done/error)实时推送到屏幕顶部悬浮 pill,agent 自己忙的时候用户不用切回 mcode 也能看到进度。", + "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 接受后零改动生效。", "author": { "name": "antianqi", "url": "https://github.com/antianqi" @@ -17,6 +17,14 @@ "powershell", "status", "ui", - "dynamic-island" - ] + "dynamic-island", + "io.minimax.mcode", + "hooks" + ], + "extensions": { + "io.minimax.mcode": { + "version": "0.1.0", + "hooks": "./io.minimax.mcode/hooks/hooks.json" + } + } } diff --git a/plugins/antianqi/mcode-island/scripts/smoke.mjs b/plugins/antianqi/mcode-island/scripts/smoke.mjs new file mode 100644 index 0000000..c3c1bd5 --- /dev/null +++ b/plugins/antianqi/mcode-island/scripts/smoke.mjs @@ -0,0 +1,408 @@ +#!/usr/bin/env node +// mcode-island v0.3.0 — pre-submit self-check for the io.minimax.mcode +// Hooks extension. Cross-platform (Windows / macOS / Linux), no +// dependencies beyond Node.js >= 18. +// +// Run from the plugin root: +// node scripts/smoke.mjs +// +// Exits 0 on full pass, 1 on any failure. Prints a per-check line +// with PASS / WARN / FAIL, then a summary. +// +// What it checks: +// 1. plugin.json: $schema / name / version / extensions.io.minimax.mcode +// 2. hooks.json: parses, top-level has `hooks` object +// 3. event catalog: every event is in the spec allowlist +// (5 `yes` in 0.2.4, 7 `forward` — `forward` is a warn, not a fail) +// 4. hook entries: no reserved fields (type, shell, prompt, http, +// agent, script, function), env does not reserve PLUGIN_ROOT / +// PLUGIN_DATA, command is either a bare executable or a path +// starting with ${PLUGIN_ROOT}/ +// 5. script files: every hook entry's referenced .ps1 file actually +// exists under io.minimax.mcode/hooks/scripts/ +// 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 { fileURLToPath } from 'node:url'; +import { dirname, join, resolve, sep } from 'node:path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const PLUGIN_ROOT = resolve(__dirname, '..'); + +const RESERVED_FIELDS = new Set([ + 'type', 'shell', 'prompt', 'http', 'agent', 'script', 'function', +]); +const RESERVED_ENV = new Set(['PLUGIN_ROOT', 'PLUGIN_DATA']); + +// 12-event catalog from proposals/hooks-detailed-spec.md. `yes` = +// confirmed in @minimax-ai/code@0.2.4 (Wso allowlist). `forward` +// = reserved by the portable spec, may or may not be wired in 0.2.4. +const EVENT_CATALOG = { + SessionStart: 'yes', + SessionEnd: 'yes', + UserPromptSubmit: 'yes', + PreToolUse: 'yes', + PostToolUse: 'yes', + Stop: 'forward', + PreCompact: 'forward', + Notification: 'forward', + SubagentStart: 'forward', + SubagentStop: 'forward', + PermissionRequest:'forward', + PermissionDenied: 'forward', +}; + +let pass = 0, warn = 0, fail = 0; +const out = (tag, msg) => { + const sym = { PASS: 'OK ', WARN: 'WARN', FAIL: 'FAIL' }[tag]; + console.log(`[${sym}] ${msg}`); + if (tag === 'PASS') pass++; + else if (tag === 'WARN') warn++; + else fail++; +}; + +const exists = async (p) => { + try { await stat(p); return true; } catch { return false; } +}; + +const readJson = async (p) => { + const raw = await readFile(p, 'utf8'); + return JSON.parse(raw); +}; + +const checkLiteralPaths = (s, where) => { + // No hardcoded /Users/ or /home/ or C:\ prefixes inside the value. + // ${PLUGIN_ROOT}/... is the only acceptable form. + if (typeof s !== 'string') return; + if (/^(\/Users\/|\/home\/|[A-Za-z]:\\|\/mnt\/)/.test(s)) { + fail++; + console.log(`[FAIL] ${where}: hardcoded host path "${s}"`); + } +}; + +const checkEntry = async (event, entry) => { + const where = `hooks.json[${event}]`; + if (typeof entry !== 'object' || entry === null) { + out('FAIL', `${where}: entry is not an object`); return; + } + + for (const key of Object.keys(entry)) { + if (RESERVED_FIELDS.has(key)) { + out('FAIL', `${where}: uses reserved field "${key}"`); + } + } + + if (entry.env) { + if (typeof entry.env !== 'object' || Array.isArray(entry.env)) { + out('FAIL', `${where}: env is not a record`); + } else { + for (const k of Object.keys(entry.env)) { + if (RESERVED_ENV.has(k)) { + out('FAIL', `${where}: env reserves "${k}"`); + } + } + } + } + + if (!entry.command) { + out('FAIL', `${where}: missing "command"`); + } else if (typeof entry.command !== 'string') { + out('FAIL', `${where}: command is not a string`); + } + + if (entry.args !== undefined && !Array.isArray(entry.args)) { + out('FAIL', `${where}: args is not an array`); + } + + if (entry.matcher !== undefined && typeof entry.matcher !== 'string') { + out('FAIL', `${where}: matcher is not a string`); + } + + if (entry.timeout !== undefined) { + if (typeof entry.timeout !== 'number' || entry.timeout <= 0) { + out('FAIL', `${where}: timeout is not a positive number`); + } else if (entry.timeout > 30000) { + out('WARN', `${where}: timeout ${entry.timeout}ms exceeds portable default 30000ms`); + } + } + + // Scan args for host-literal paths. The command itself we + // already validated above; args often contain the actual script + // path. We don't run any path-resolution here — that's the + // Runtime's job. We only check that nothing is hardcoded. + for (const a of (entry.args || [])) { + checkLiteralPaths(a, `${where}.args[]`); + } + + // Find the script path inside the args (last .ps1/.mjs/.js/.ps1 + // token that isn't a switch). We don't need exact matching — we + // just check that at least one script file under + // io.minimax.mcode/hooks/scripts/ exists and is referenced. + const scriptArg = (entry.args || []).find( + (a) => typeof a === 'string' && /\.(ps1|mjs|js)$/i.test(a) + ); + if (scriptArg) { + // Strip ${PLUGIN_ROOT}/ prefix and resolve relative to PLUGIN_ROOT. + const cleaned = scriptArg.replace(/^\$\{PLUGIN_ROOT\}/, ''); + const absolute = join(PLUGIN_ROOT, cleaned); + if (!(await exists(absolute))) { + out('FAIL', `${where}: script not found: ${cleaned}`); + } else { + out('PASS', `${where}: script ${cleaned} exists`); + } + } +}; + +const main = async () => { + console.log(`mcode-island v0.3.0 self-check`); + console.log(`plugin root: ${PLUGIN_ROOT}`); + console.log('-'.repeat(60)); + + // 1. plugin.json + const pluginJsonPath = join(PLUGIN_ROOT, 'plugin.json'); + if (!(await exists(pluginJsonPath))) { + out('FAIL', 'plugin.json missing'); return finish(); + } + let plugin; + try { + plugin = await readJson(pluginJsonPath); + out('PASS', 'plugin.json parses'); + } catch (e) { + out('FAIL', `plugin.json: ${e.message}`); return finish(); + } + + if (plugin.$schema !== 'https://agent-plugins.org/schemas/1.0.0/plugin.schema.json') { + out('FAIL', `plugin.json: $schema is "${plugin.$schema}", expected agent-plugins 1.0.0`); + } else { + out('PASS', 'plugin.json: $schema is agent-plugins 1.0.0'); + } + if (plugin.name !== 'mcode-island') { + out('FAIL', `plugin.json: name is "${plugin.name}"`); + } 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"`); + } else { + out('PASS', `plugin.json: version is "${plugin.version}"`); + } + + if (!plugin.extensions || !plugin.extensions['io.minimax.mcode']) { + out('FAIL', 'plugin.json: missing extensions["io.minimax.mcode"]'); + } else { + const ext = plugin.extensions['io.minimax.mcode']; + out('PASS', 'plugin.json: extensions.io.minimax.mcode is present'); + if (!ext.hooks) { + out('FAIL', 'plugin.json: extensions.io.minimax.mcode.hooks is missing'); + } else { + const hooksRel = ext.hooks.replace(/^\.\//, ''); + const hooksAbs = join(PLUGIN_ROOT, hooksRel); + if (!(await exists(hooksAbs))) { + out('FAIL', `plugin.json: extensions.io.minimax.mcode.hooks points to missing file ${hooksRel}`); + } else { + out('PASS', `plugin.json: extensions.io.minimax.mcode.hooks resolves to ${hooksRel}`); + } + } + } + + // 2. hooks.json + const hooksJsonPath = join(PLUGIN_ROOT, 'io.minimax.mcode', 'hooks', 'hooks.json'); + if (!(await exists(hooksJsonPath))) { + out('FAIL', 'io.minimax.mcode/hooks/hooks.json missing'); return finish(); + } + let hooksDoc; + try { + hooksDoc = await readJson(hooksJsonPath); + out('PASS', 'io.minimax.mcode/hooks/hooks.json parses'); + } catch (e) { + out('FAIL', `io.minimax.mcode/hooks/hooks.json: ${e.message}`); return finish(); + } + + const hooksRoot = hooksDoc.hooks || hooksDoc; + if (typeof hooksRoot !== 'object' || Array.isArray(hooksRoot) || hooksRoot === null) { + out('FAIL', 'io.minimax.mcode/hooks/hooks.json: `hooks` is not an object keyed by event'); + return finish(); + } + out('PASS', 'io.minimax.mcode/hooks/hooks.json: `hooks` is an object'); + + // 2b. closed-schema conformance (round-4 R21-1). + // The companion proposal (MiniMax-Code-Plugins PR #20) defines the + // root keys as a closed allowlist of { $schema, hooks }. Anything + // else (notably the historical `_comment` field) is rejected. We + // import the shared validator to avoid drifting from the proposal. + try { + const { validateHooksDocument, HOOK_SCHEMA } = await import( + fileURLToPath(new URL('../../../../../scripts/lib/validation.mjs', import.meta.url)) + ).catch(() => ({})); + if (typeof validateHooksDocument === 'function') { + try { + validateHooksDocument(hooksDoc, 'mcode-island/hooks.json'); + out('PASS', 'hooks.json conforms to closed schema (HOOK_DOCUMENT_FIELDS)'); + } catch (e) { + // Round-4: a stray _comment or any unknown root key + // becomes a hard FAIL, not a soft WARN. + out('FAIL', `hooks.json: ${e.message} (closed schema: $schema + hooks only)`); + return finish(); + } + if (hooksDoc.$schema && hooksDoc.$schema !== HOOK_SCHEMA) { + out('FAIL', `hooks.json: $schema is ${hooksDoc.$schema} but the proposal pins ${HOOK_SCHEMA}`); + return finish(); + } + if (hooksDoc.$schema === HOOK_SCHEMA) { + out('PASS', `hooks.json: $schema pinned to ${HOOK_SCHEMA}`); + } + } else { + // Fallback: do the closed-schema check inline so the test + // does not depend on the validator being importable. + const known = new Set(['$schema', 'hooks']); + const unknown = Object.keys(hooksDoc).filter((k) => !known.has(k)); + if (unknown.length > 0) { + out('FAIL', `hooks.json: unknown root field(s) ${unknown.map((k) => JSON.stringify(k)).join(', ')} (closed schema: $schema + hooks only)`); + return finish(); + } + out('PASS', 'hooks.json: closed schema (no unknown root fields)'); + } + } catch (e) { + out('WARN', `hooks.json: closed-schema check skipped: ${e.message}`); + } + + // 3. event catalog + const eventNames = Object.keys(hooksRoot); + if (eventNames.length === 0) { + out('FAIL', 'io.minimax.mcode/hooks/hooks.json: no events declared'); + } + for (const ev of eventNames) { + if (!(ev in EVENT_CATALOG)) { + out('FAIL', `event "${ev}" is not in the portable spec allowlist`); + } else if (EVENT_CATALOG[ev] === 'forward') { + out('WARN', `event "${ev}" is "forward" (not confirmed in @minimax-ai/code@0.2.4)`); + } else { + out('PASS', `event "${ev}" is "yes" (confirmed in 0.2.4)`); + } + } + for (const ev of Object.keys(EVENT_CATALOG)) { + if (!eventNames.includes(ev)) { + out('WARN', `spec allowlist includes "${ev}" but it is not declared in hooks.json`); + } + } + + // 4. entries + for (const [event, entries] of Object.entries(hooksRoot)) { + if (!Array.isArray(entries)) { + out('FAIL', `hooks.json[${event}]: not an array`); continue; + } + for (const entry of entries) { + await checkEntry(event, entry); + } + } + + // 5. _lib.ps1 exists and parses (basic check) + const libPath = join(PLUGIN_ROOT, 'io.minimax.mcode', 'hooks', 'scripts', '_lib.ps1'); + if (!(await exists(libPath))) { + out('FAIL', 'io.minimax.mcode/hooks/scripts/_lib.ps1 missing'); + } else { + const lib = await readFile(libPath, 'utf8'); + for (const fn of ['Read-HookStdin', 'Push-Island', 'Test-IsSelfPush', 'Format-ToolSummary']) { + if (!lib.includes(`function ${fn}`)) { + out('WARN', `_lib.ps1: function ${fn} not found`); + } + } + out('PASS', '_lib.ps1: shared helper present'); + } + + // 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` + // would silently change the user-facing permission flow. The portable + // spec (PR #20) added `ask` exactly so observers can opt into + // "ask the user" without becoming the permission owner. This lock + // prevents a future change from regressing that invariant. + const permReqPath = join(PLUGIN_ROOT, 'io.minimax.mcode', 'hooks', 'scripts', 'permission-request.ps1'); + if (!(await exists(permReqPath))) { + out('FAIL', 'permission-request.ps1 missing (drift lock skipped)'); + } else { + const permReq = await readFile(permReqPath, 'utf8'); + const decisionMatch = permReq.match(/WriteLine\(\s*'([^']*\{[^']*\})'\s*\)/); + if (!decisionMatch) { + out('FAIL', 'permission-request.ps1: cannot locate WriteLine decision JSON'); + } else { + const decisionJson = decisionMatch[1]; + let parsed; + try { parsed = JSON.parse(decisionJson); } + catch (e) { + out('FAIL', `permission-request.ps1: decision JSON is not valid JSON: ${e.message}`); + } + if (parsed) { + if (parsed.decision !== 'ask') { + out('FAIL', `permission-request.ps1: decision is "${parsed.decision}", expected "ask" (observer opt-in, per PR #20). Returning "allow" or "deny" from an observer Hook silently changes the user-facing permission flow.`); + } else { + out('PASS', `permission-request.ps1: decision is locked to "ask" (observer opt-in)`); + } + if (!parsed.reason || typeof parsed.reason !== 'string') { + out('FAIL', 'permission-request.ps1: missing or non-string `reason` field'); + } else { + out('PASS', 'permission-request.ps1: reason field present'); + } + } + } + } + + // 5c. Drift lock: README must not say `{"decision":"allow"}` for + // PermissionRequest. The v0.2.1 baseline docstring is the most + // common place this regresses, since the script changed from + // `allow` to `ask` between v0.2.1 and v0.3.0. + const readmePath = join(PLUGIN_ROOT, 'README.md'); + if (await exists(readmePath)) { + const readme = await readFile(readmePath, 'utf8'); + if (/PermissionRequest[\s\S]{0,400}decision[\s\S]{0,40}"allow"/i.test(readme)) { + out('FAIL', 'README.md: contains "decision":"allow" near PermissionRequest (the v0.3.0 spec uses "ask")'); + } else { + out('PASS', 'README.md: no stale "decision":"allow" near PermissionRequest'); + } + } + + // 6. cross-platform: scan all .ps1 files for hardcoded paths + console.log('-'.repeat(60)); + console.log('cross-platform scan:'); + const scriptsDir = join(PLUGIN_ROOT, 'io.minimax.mcode', 'hooks', 'scripts'); + for (const fname of [ + '_lib.ps1', 'session-start.ps1', 'session-end.ps1', 'user-prompt-submit.ps1', + 'pre-tool-use.ps1', 'post-tool-use.ps1', 'stop.ps1', 'pre-compact.ps1', + 'notification.ps1', 'subagent-start.ps1', 'subagent-stop.ps1', + 'permission-request.ps1', 'permission-denied.ps1', + ]) { + const p = join(scriptsDir, fname); + if (!(await exists(p))) continue; + const text = await readFile(p, 'utf8'); + // Look for hardcoded host paths inside string literals. + // ${PLUGIN_ROOT} is fine; ${env:...} is fine; $PSScriptRoot is fine. + // We only flag literal C:\, /Users/, /home/, /mnt/ outside of comments. + const lines = text.split(/\r?\n/); + let bad = 0; + for (const [i, line] of lines.entries()) { + // Skip pure comment lines. + if (/^\s*#/.test(line)) continue; + // Match a literal path-looking token (not preceded by $). + const m = line.match(/(^|[^$])(\/Users\/|\/home\/|[A-Za-z]:\\[^$]*|\/mnt\/[^$\s]*)/); + if (m) { + out('FAIL', `${fname}:${i+1}: hardcoded host path "${m[2].trim()}"`); + bad++; + } + } + if (bad === 0) out('PASS', `${fname}: no hardcoded host paths`); + } + + finish(); +}; + +const finish = () => { + console.log('-'.repeat(60)); + console.log(`summary: ${pass} pass, ${warn} warn, ${fail} fail`); + process.exit(fail > 0 ? 1 : 0); +}; + +main().catch((e) => { + console.error('FATAL:', e.message); + process.exit(2); +}); diff --git a/plugins/antianqi/mcode-island/scripts/test-windows-workflow-local.ps1 b/plugins/antianqi/mcode-island/scripts/test-windows-workflow-local.ps1 new file mode 100644 index 0000000..4284119 --- /dev/null +++ b/plugins/antianqi/mcode-island/scripts/test-windows-workflow-local.ps1 @@ -0,0 +1,217 @@ +# test-windows-workflow-local.ps1 +# +# Local runner that mirrors `.github/workflows/mcode-island-windows.yml` +# 1:1 on a Windows host. Use this when: +# - The PR is from a fork and GitHub Actions has not yet been +# approved by a maintainer (so the workflow file is in the PR +# but does not run on PR pushes), or +# - 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 +# 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 +# +# Usage (from the repo root, with PowerShell 7+): +# 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 +# "OK Step N: ..." line on success or a thrown exception on failure. +# +# Caveat: this script uses an isolated APPDATA at +# %TEMP%\mcode-island-apphome-local\ so it does NOT touch the host's +# real mcode-island config. The Windows PowerShell 5.1 child spawned +# in step 3 is given an explicit -Environment that overrides APPDATA; +# this is necessary because Windows PowerShell 5.1 does not inherit +# the parent pwsh's $env:APPDATA modification (it re-derives from +# %USERPROFILE% on startup). + +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 +$OutputEncoding = [System.Text.Encoding]::UTF8 +try { chcp 65001 | Out-Null } catch {} + +$ErrorActionPreference = 'Stop' +$repoRoot = (Get-Location).Path + +# --- Shared fixtures ---------------------------------------------------- + +$apphome = New-Item -ItemType Directory -Path (Join-Path $env:TEMP 'mcode-island-apphome-local') -Force +$env:APPDATA = $apphome.FullName +$FAKE = 'ci-fake-oauth-token-1234567890abcdef' +$env:FAKE_TOKEN = $FAKE + +# Defensive: unset pre-existing token env so set-token's -Show reports +# the config.json source (its fallback contract). +foreach ($name in 'MINIMAX_OAUTH_TOKEN', 'MINIMAX_API_KEY') { + if (Test-Path "env:$name") { Remove-Item "env:$name" -ErrorAction SilentlyContinue } +} + +Write-Host "=== mcode-island windows-latest local runner ===" +Write-Host "Repo: $repoRoot" +Write-Host "Isolated APPDATA: $($apphome.FullName)" +Write-Host "" + +# --- Step 1: parse all .ps1 --------------------------------------------- + +Write-Host "--- Step 1: parse all .ps1 files ---" +$root = Join-Path $repoRoot 'plugins/antianqi/mcode-island' +$files = @(Get-ChildItem -Path $root -Recurse -Filter *.ps1) +if ($files.Count -eq 0) { throw "Step 1: no .ps1 files under $root" } +$bad = 0 +foreach ($f in $files) { + $errs = $null + $null = [System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$errs) + if ($errs -and $errs.Count -gt 0) { + $rel = $f.FullName.Substring($root.Length + 1) -replace '\\', '/' + Write-Host " PARSE FAIL: $rel" + $errs | ForEach-Object { Write-Host " line $($_.Extent.StartLineNumber):col $($_.Extent.StartColumnNumber) $($_.Message)" } + $bad++ + } +} +if ($bad -gt 0) { throw "Step 1: $bad / $($files.Count) .ps1 files failed to parse" } +Write-Host "OK Step 1: $($files.Count) / $($files.Count) .ps1 files parsed without syntax errors" +Write-Host "" + +# --- Step 2: token set / show / clear --------------------------------- + +Write-Host "--- Step 2: token set / show / clear roundtrip ---" +$set = Join-Path $repoRoot 'plugins/antianqi/mcode-island/set-token.ps1' + +# 2a +$r1 = (& $set $FAKE | Out-String).Trim() +if ($r1 -notmatch '^已写入') { throw "Step 2a: expected '已写入' header, got: $r1" } +$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" } + +# 2b +$r2 = (& $set -Show | Out-String).Trim() +if ($r2 -notmatch 'config\.json planApiToken') { throw "Step 2b: expected 'config.json planApiToken', 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" } + +# 2c +$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['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" } + +Write-Host "OK Step 2: set / show / clear roundtrip (4 / 4 checks)" +Write-Host "" + +# --- Step 3: hook stdin / stdout --------------------------------------- + +Write-Host "--- Step 3: hook stdin / stdout (PreToolUse) ---" +$hook = Join-Path $repoRoot 'plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/pre-tool-use.ps1' +$stdinFile = Join-Path $env:TEMP 'hook-stdin-pretooluse-local.json' +$stdinJson = '{"session_id":"ci-fake-session","transcript_path":"C:\\fake\\transcript","cwd":"C:\\fake\\cwd","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"echo ci-pretooluse-test"}}' +Set-Content -Path $stdinFile -Value $stdinJson -Encoding utf8 -NoNewline + +$statusFile = Join-Path $apphome 'mcode-island\status.json' +if (Test-Path $statusFile) { Remove-Item $statusFile -Force } + +# Windows PowerShell 5.1 re-derives $env:APPDATA from %USERPROFILE% on +# startup, so $env:APPDATA set in the parent pwsh does not propagate. +# Pass -Environment explicitly. +$childEnv = [System.Collections.Generic.Dictionary[string,string]]::new() +foreach ($k in [System.Environment]::GetEnvironmentVariables('Process').Keys) { + $childEnv[$k] = [System.Environment]::GetEnvironmentVariable($k) +} +$childEnv['APPDATA'] = $apphome.FullName + +$p = Start-Process -FilePath 'powershell' ` + -ArgumentList @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $hook) ` + -NoNewWindow -RedirectStandardInput $stdinFile ` + -Environment $childEnv ` + -PassThru +$p.WaitForExit() +if ($p.ExitCode -ne 0) { throw "Step 3: pre-tool-use.ps1 exited with code $($p.ExitCode)" } + +if (-not (Test-Path $statusFile)) { throw "Step 3: hook did not write $statusFile" } +$status = Get-Content $statusFile -Raw | ConvertFrom-Json +if ($status.state -ne 'working') { throw "Step 3: status.state got '$($status.state)' (want 'working')" } +if ($status.source -ne 'agent') { throw "Step 3: status.source got '$($status.source)' (want 'agent')" } +if ($status.message -notmatch '^Bash\s*:') { throw "Step 3: status.message got '$($status.message)' (want 'Bash : ...')" } +if ($status.message -notmatch 'ci-pretooluse-test') { throw "Step 3: status.message missing 'ci-pretooluse-test'" } + +Write-Host "OK Step 3: hook PreToolUse OK: state=$($status.state) source=$($status.source)" +Write-Host "" + +# --- Step 4: mocked usage-API roundtrip ------------------------------- + +Write-Host "--- Step 4: mocked usage-API roundtrip ---" +$probe = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0) +$probe.Start() +$freePort = [int]$probe.LocalEndpoint.Port +$probe.Stop() +Write-Host "Free port: $freePort" + +$job = Start-Job -ScriptBlock { + param($port) + $listener = [System.Net.HttpListener]::new() + $listener.Prefixes.Add("http://127.0.0.1:$port/") + $listener.Start() + try { + $ctx = $listener.GetContext() + $auth = $ctx.Request.Headers['Authorization'] + $path = $ctx.Request.Url.AbsolutePath + $body = '{"model_remains":[{"model":"general","remainingPct":84,"resetMs":16200000}]}' + $bytes = [System.Text.Encoding]::UTF8.GetBytes($body) + $ctx.Response.StatusCode = 200 + $ctx.Response.ContentType = 'application/json' + $ctx.Response.ContentLength64 = $bytes.Length + $ctx.Response.OutputStream.Write($bytes, 0, $bytes.Length) + $ctx.Response.Close() + [PSCustomObject]@{ auth = $auth; path = $path } + } finally { + $listener.Stop() + $listener.Close() + } +} -ArgumentList $freePort + +try { + # 4a) Token resolution: env wins over config.json + $env:MINIMAX_OAUTH_TOKEN = $FAKE + $cfgDir = Join-Path $apphome 'mcode-island' + if (-not (Test-Path $cfgDir)) { New-Item -ItemType Directory -Path $cfgDir -Force | Out-Null } + @{ planApiToken = 'config-token-should-not-be-used' } | ConvertTo-Json | + Out-File -FilePath (Join-Path $cfgDir 'config.json') -Encoding utf8 + + # 4b) The detector requests this URL; we point it at the local listener + $url = "http://127.0.0.1:$freePort/v1/coding_plan/remains" + $headers = @{ + 'Authorization' = "Bearer $env:MINIMAX_OAUTH_TOKEN" + 'MM-API-Source' = 'MiniMax-MCP' + } + $resp = Invoke-RestMethod -Uri $url -Headers $headers -TimeoutSec 10 -Method Get -ErrorAction Stop + + # 4c) Bearer + path assertion + $mock = $job | Wait-Job -Timeout 15 | Receive-Job + if (-not $mock) { throw "Step 4: listener job did not complete within 15s" } + if ($mock.auth -ne "Bearer $FAKE") { throw "Step 4: mock saw auth='$($mock.auth)' (want 'Bearer $FAKE')" } + if ($mock.path -ne '/v1/coding_plan/remains') { throw "Step 4: mock saw path='$($mock.path)' (want '/v1/coding_plan/remains')" } + + # 4d) Response shape + if (-not $resp -or -not $resp.model_remains) { throw "Step 4: missing model_remains in response" } + $first = @($resp.model_remains)[0] + if ($first.remainingPct -ne 84 -or $first.resetMs -ne 16200000) { + throw "Step 4: first model_remains entry got pct=$($first.remainingPct) reset=$($first.resetMs) (want 84 / 16200000)" + } + + Write-Host "OK Step 4: mock auth='$($mock.auth)' path='$($mock.path)' first entry=remainingPct=$($first.remainingPct)% resetMs=$($first.resetMs)" +} +finally { + if ($job.State -ne 'Completed') { Stop-Job $job } + Remove-Job $job -Force +} + +Write-Host "" +Write-Host "=== All 4 steps OK ===" +exit 0 diff --git a/plugins/antianqi/mcode-island/set-token.ps1 b/plugins/antianqi/mcode-island/set-token.ps1 new file mode 100644 index 0000000..2e28b57 --- /dev/null +++ b/plugins/antianqi/mcode-island/set-token.ps1 @@ -0,0 +1,81 @@ +# mcode-island - 设置 5h 用量 API token +# 用法: +# set-token.ps1 # 写 token 到 %APPDATA%\mcode-island\config.json +# set-token.ps1 -Show # 显示当前是否已配置 +# set-token.ps1 -Clear # 删除 token +# +# token 也可以从环境变量 MINIMAX_OAUTH_TOKEN 自动读,优先级: +# 1. $env:MINIMAX_OAUTH_TOKEN +# 2. $env:MINIMAX_API_KEY +# 3. config.json 的 planApiToken +# 所以通常不用手动 set-token,除非要换 token。 + +param( + [Parameter(Position=0)] + [string]$Token, + [switch]$Show, + [switch]$Clear +) + +$ErrorActionPreference = 'Stop' +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + +$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' + +function Read-Cfg { + if (Test-Path $cfgFile) { + try { return (Get-Content $cfgFile -Raw -Encoding UTF8 | ConvertFrom-Json) } catch {} + } + return [PSCustomObject]@{} +} + +function Write-Cfg($obj) { + $obj | ConvertTo-Json | Out-File -FilePath $cfgFile -Encoding UTF8 +} + +if ($Show) { + $cfg = Read-Cfg + $hasCfg = $cfg.PSObject.Properties['planApiToken'] -and $cfg.planApiToken + $hasEnv = $env:MINIMAX_OAUTH_TOKEN -or $env:MINIMAX_API_KEY + if ($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)" + } else { + Write-Output "token 未配置" + } + exit 0 +} + +if ($Clear) { + $cfg = Read-Cfg + if ($cfg.PSObject.Properties['planApiToken']) { + $cfg.PSObject.Properties.Remove('planApiToken') + Write-Cfg $cfg + Write-Output '已从 config.json 删除 planApiToken' + } else { + Write-Output 'config.json 没有 planApiToken,无需删除' + } + exit 0 +} + +if (-not $Token) { + Write-Output '用法: set-token.ps1 | -Show | -Clear' + exit 1 +} + +$cfg = Read-Cfg +if ($cfg.PSObject.Properties['planApiToken']) { + $cfg.planApiToken = $Token +} else { + $cfg | Add-Member -NotePropertyName 'planApiToken' -NotePropertyValue $Token +} +Write-Cfg $cfg +$masked = $Token.Substring(0, [Math]::Min(10, $Token.Length)) + '...' +Write-Output "已写入 $cfgFile" +Write-Output " planApiToken: $masked" +Write-Output '重启 detector 后生效: mcode-island detect-off && mcode-island detect-on' diff --git a/plugins/antianqi/mcode-island/skills/mcode-island/SKILL.md b/plugins/antianqi/mcode-island/skills/mcode-island/SKILL.md index b906066..6ab1c98 100644 --- a/plugins/antianqi/mcode-island/skills/mcode-island/SKILL.md +++ b/plugins/antianqi/mcode-island/skills/mcode-island/SKILL.md @@ -1,11 +1,11 @@ --- 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. +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. On mcode 0.2.4+ with the `io.minimax.mcode` Hooks extension enabled (forward-compatible with MiniMax-Code-Plugins PR #20), every tool lifecycle event fires a script under `io.minimax.mcode/hooks/scripts/` automatically — the agent does not need to push states manually. On older mcode or when the extension is not yet active, fall back to calling `notify-island.ps1` before and after each tool call, or use `wrap-tool.ps1` for the bash path. 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`). +compatibility: Requires Windows 10/11 with PowerShell 5.1+ and the mcode-island widget running (started via `mcode-island start` or `autostart.ps1 -Enable`). Hook-driven mode additionally requires mcode 0.2.4+ with the `io.minimax.mcode` extension namespace accepted by the registry validator. metadata: author: antianqi - version: "0.1.0" + version: "0.3.0" --- # mcode-island — 桌面灵动岛状态通知 @@ -30,7 +30,47 @@ 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 +## Two ways to drive the pill + +### Mode A — Hook-driven (mcode 0.2.4+ with `io.minimax.mcode`) + +When mcode accepts the `io.minimax.mcode` client extension, the runtime spawns +the script under `io.minimax.mcode/hooks/scripts/.ps1` for every matching +lifecycle event. The agent does **not** need to push state manually. + +| event | script | pill state | +| ----------------- | --------------------------------- | ----------- | +| `SessionStart` | `session-start.ps1` | `idle` | +| `SessionEnd` | `session-end.ps1` | `idle` | +| `UserPromptSubmit`| `user-prompt-submit.ps1` | `thinking` | +| `PreToolUse` | `pre-tool-use.ps1` | `working` | +| `PostToolUse` | `post-tool-use.ps1` | `done`/`error` | +| `Stop` | `stop.ps1` | `done` | +| `PreCompact` | `pre-compact.ps1` | `thinking` | +| `Notification` | `notification.ps1` | `idle` | +| `SubagentStart` | `subagent-start.ps1` (CODEX only) | `working` | +| `SubagentStop` | `subagent-stop.ps1` (CODEX only) | `done` | +| `PermissionRequest`| `permission-request.ps1` (returns `{"decision":"allow"}` so the runtime's fail-closed default does not deny) | `waiting` | +| `PermissionDenied`| `permission-denied.ps1` | `error` | + +The hooks conform to the portable spec proposed in +`MiniMax-Code-Plugins` PR #20. Each script reads the JSON event payload from +stdin, calls `notify-island.ps1` with the appropriate state, and exits 0 +(decision-bearing events also write a JSON decision to stdout). Self-push +filtering prevents the pill from churning when the agent calls +`notify-island.ps1` directly through Bash. + +If you are running on mcode 0.2.4+ and the pill is updating itself before you +push anything, Mode A is active. Otherwise fall through to Mode B. + +### Mode B — Agent-pushed (legacy, always works) + +For older mcode, or when the `io.minimax.mcode` extension is not yet active +(registry validator has not accepted the namespace), the agent pushes state +through `notify-island.ps1` directly. The `mcode-status-detect.ps1` detector +also infers state from the runtime's `ledger.jsonl` / `messages.jsonl`, so +the pill will still move — your manual pushes just sharpen the message and +cover edge cases (notably `ask_user`). | moment | state | example message | | ----------------------------------------------------- | --------- | ------------------------------ | @@ -38,13 +78,23 @@ Click the pill to switch focus back to the originating terminal tab. Run | 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"` | +| about to call `ask_user` (user must pick) | `waiting` | `"ask_user: 2 options"` | +| user answered `ask_user`, resuming work | `done` | `"ask_user answered"` | | tool failed / threw / non-zero exit | `error` | `"compile failed: missing import"` | | conversation idle, waiting for user | `idle` | (none) | +**`ask_user` is a special tool** — the detector cannot infer it is a "wait for +user" moment (it looks like any other tool call to the session log). When in +Mode B, the agent MUST push `waiting` immediately before invoking `ask_user`, +and `done` immediately after the user answers; otherwise the pill will sit in +`working` (yellow/blue) while the user is actually being asked to decide. In +Mode A, the same coverage comes for free because `ask_user` is a tool call +that fires `PreToolUse`/`PostToolUse`. + **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) +## Copyable example (agent side, Mode B) 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, @@ -62,6 +112,12 @@ then call `wrap-tool.ps1` to publish the outcome: (default `[1]`) → `waiting`, anything else → `error`. The wrapper returns the exit code unchanged so the calling shell still sees it. +The wrapper accepts `-Tool bash|read|write|edit|glob|grep|web|task|notebook` and +emits a tool-specific `done` message (e.g. `read C:\path`, `edited file.cs`, +`npm test 完成`) so the pill text is informative. For read/write/edit/glob/grep +the wrapper itself does not execute the command — mcode's own tool does; this +script only publishes the state. + For other tools (read/write/edit) — and for any state push that is not a single command — call `notify-island.ps1` directly: @@ -80,10 +136,11 @@ 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. +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. ## User-side management @@ -91,7 +148,7 @@ it, and `mcode-island show` re-raises the hidden window in under 1 second. 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 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 autostart-on REM register for Windows logon @@ -121,34 +178,64 @@ All widget state lives under `%APPDATA%\mcode-island\`: | `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. +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`), +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. ## 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 +├── 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 +├── mcode-status-detect.ps1 # runtime-state detector +├── io.minimax.mcode/ # client extension (PR #20 spec) +│ └── hooks/ +│ ├── hooks.json # 12-event declaration +│ └── scripts/ +│ ├── _lib.ps1 # shared helper +│ ├── session-start.ps1 +│ ├── session-end.ps1 +│ ├── user-prompt-submit.ps1 +│ ├── pre-tool-use.ps1 +│ ├── post-tool-use.ps1 +│ ├── stop.ps1 +│ ├── pre-compact.ps1 +│ ├── notification.ps1 +│ ├── subagent-start.ps1 +│ ├── subagent-stop.ps1 +│ ├── permission-request.ps1 +│ └── permission-denied.ps1 +├── 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`). +- Windows 10/11 only (uses WPF, `user32`, and `kernel32` P/Invoke). - Single widget per user session. +- Hook-driven mode requires mcode 0.2.4+ Runtime. The portable spec + (`io.minimax.mcode` client extension) is still pending merge in + `MiniMax-Code-Plugins` PR #20; until the registry validator accepts the + namespace, the hooks subdirectory is dormant and the plugin falls back to + Mode B (agent-pushed + detector). - 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. +- `wrap-tool.ps1` is a **status publisher only** — it never executes the + command itself (mcode's tool does). The agent still runs every read / write / + edit through mcode and then calls `wrap-tool.ps1` to publish the outcome. + This avoids shell-injection ambiguity from a prior `Invoke-Expression` design. diff --git a/plugins/antianqi/mcode-island/start-detect-island.ps1 b/plugins/antianqi/mcode-island/start-detect-island.ps1 index e86b46d..8ee1f31 100644 --- a/plugins/antianqi/mcode-island/start-detect-island.ps1 +++ b/plugins/antianqi/mcode-island/start-detect-island.ps1 @@ -26,7 +26,12 @@ if (Test-Path $pidFile) { } } -$args = @('-NoProfile', '-STA', '-WindowStyle', 'Hidden', '-ExecutionPolicy', 'Bypass', '-File', "`"$detectScript`"") -$proc = Start-Process powershell.exe -ArgumentList $args -PassThru +# 同 start-island.ps1:CreateNoWindow 避免控制台窗口冒进任务栏 +$psi = New-Object System.Diagnostics.ProcessStartInfo +$psi.FileName = 'powershell.exe' +$psi.Arguments = "-NoProfile -STA -ExecutionPolicy Bypass -File `"$detectScript`"" +$psi.UseShellExecute = $false +$psi.CreateNoWindow = $true +$proc = [System.Diagnostics.Process]::Start($psi) 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 index c0b47a6..3704a89 100644 --- a/plugins/antianqi/mcode-island/start-island.ps1 +++ b/plugins/antianqi/mcode-island/start-island.ps1 @@ -34,9 +34,16 @@ if (Test-Path $pidFile) { } } -# 新进程启动 widget -$args = @('-NoProfile', '-STA', '-WindowStyle', 'Hidden', '-ExecutionPolicy', 'Bypass', '-File', "`"$widget`"") -$proc = Start-Process powershell.exe -ArgumentList $args -PassThru +# 新进程启动 widget。 +# 用 ProcessStartInfo + CreateNoWindow = $true 是关键:-WindowStyle Hidden 只会设 SW_HIDE 样式, +# 控制台窗口其实还存在,偶尔会冒进任务栏被误关。CreateNoWindow 走 Win32 CREATE_NO_WINDOW, +# 从根上就不生成控制台窗口,任务栏/Alt-Tab 都不会看到。 +$psi = New-Object System.Diagnostics.ProcessStartInfo +$psi.FileName = 'powershell.exe' +$psi.Arguments = "-NoProfile -STA -ExecutionPolicy Bypass -File `"$widget`"" +$psi.UseShellExecute = $false +$psi.CreateNoWindow = $true +$proc = [System.Diagnostics.Process]::Start($psi) # 写 PID(先写,后面 status / stop 都靠这个) Set-Content -Path $pidFile -Value $proc.Id -Encoding ASCII diff --git a/plugins/antianqi/mcode-island/wrap-tool.ps1 b/plugins/antianqi/mcode-island/wrap-tool.ps1 index 77960b5..c1ad09e 100644 --- a/plugins/antianqi/mcode-island/wrap-tool.ps1 +++ b/plugins/antianqi/mcode-island/wrap-tool.ps1 @@ -51,9 +51,22 @@ if ($ExitCode -lt 0) { exit 0 } -# 跑完了:根据退出码推 done/waiting/error +# Tool-specific 完成文案。匹配 detector 在 messages.jsonl 里看到的 toolName 形式 +$doneMsg = switch ($Tool) { + 'bash' { "$brief 完成" } + 'read' { if ($brief) { "read $brief" } else { "read 完成" } } + 'write' { if ($brief) { "wrote $brief" } else { "write 完成" } } + 'edit' { if ($brief) { "edited $brief" } else { "edit 完成" } } + 'glob' { if ($Glob) { "glob $Glob" } elseif ($brief) { "glob $brief" } else { "glob 完成" } } + 'grep' { if ($Pattern) { "grep $Pattern" } else { "grep 完成" } } + 'web' { 'web 完成' } + 'task' { 'task 完成' } + 'notebook' { 'notebook 完成' } + default { "$Tool 完成" } +} + if ($ExitCode -eq 0) { - & $notify -State done -Message "$Tool 完成" | Out-Null + & $notify -State done -Message $doneMsg | Out-Null exit 0 } elseif ($WaitingExitCodes -contains $ExitCode) { & $notify -State waiting -Message "$Tool 等待审批 (exit=$ExitCode)" | Out-Null