Skip to content

Add mcode-island plugin: Windows Dynamic Island status pill for MiniMax Code agents - #17

Open
antianqi wants to merge 6 commits into
MiniMax-AI:mainfrom
antianqi:add-mcode-island
Open

Add mcode-island plugin: Windows Dynamic Island status pill for MiniMax Code agents#17
antianqi wants to merge 6 commits into
MiniMax-AI:mainfrom
antianqi:add-mcode-island

Conversation

@antianqi

@antianqi antianqi commented Aug 22, 2026

Copy link
Copy Markdown

mcode-island — Windows Dynamic Island for MiniMax Code agents

A Skill-first Plugin that surfaces the agent's working state in a small WPF pill
anchored to the top center of the primary display, so the user can leave the
terminal in the background and still see exactly what the agent is doing.

idle thinking working waiting done error

The agent's only contract with the widget is: write JSON to
%APPDATA%\mcode-island\status.json (or call the notify-island.ps1 helper
that does that for you). The widget polls that file every 400 ms.

The problem this solves

While the agent runs a long tool call (compile, install, test, refactor), the
user often switches away from the terminal to read code, check docs, or browse
the web. There is no visible progress signal. The agent may also be paused on
a permission prompt, or have failed silently. mcode-island makes all of
that visible at a glance, without forcing the user to switch back.

Copyable example

In the agent loop, wrap every bash call through the bundled wrapper:

& "<plugin install dir>\wrap-tool.ps1" `
    -Tool bash -Command "npm test" -Description "run tests"

State flow this triggers automatically:

  1. working — bash: run tests (pushed before the command runs)
  2. on exit 0: done — bash 完成
  3. on exit 1 (configurable): waiting — bash 等待审批 (exit=1)
  4. on other non-zero: error — bash 失败 (exit=N)

For other tools (read / write / edit), the agent pushes state directly
via notify-island.ps1 before and after each tool call. The Skill body in
skills/mcode-island/SKILL.md documents the exact timing.

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), and mcode-island show re-raises the hidden window in under one
second.

Requirements

requirement version / note
Windows 10 1809+ or 11 (uses WPF, user32 / kernel32)
PowerShell 5.1 (ships with Windows 10/11) or PowerShell 7
.NET WPF runtime 4.x (ships with Windows 10/11)
execution policy Bypass for this directory; not changed globally
network access none
accounts none
paid services none

The plugin contains no node_modules, no native binaries, no symlinks, no
installers, no private endpoints, no telemetry.

Network and data behavior

  • The widget never makes a network request.
  • No telemetry, no analytics, no auto-update checks.
  • All state lives under %APPDATA%\mcode-island\:
    status.json, caller.json, config.json, widget.pid, island.log,
    widget.log, show.signal.
  • The only registry write is to HKCU\Software\Microsoft\Windows\CurrentVersion\Run
    for logon auto-start (opt-in, user runs autostart.ps1 -Enable).
  • No data leaves the local machine.

Test evidence

This plugin was exercised on Windows 11 24H2 with PowerShell 5.1 against a
live MiniMax Code session. Concrete observations captured during development:

  • 59 state transitions in island.log over a multi-hour session
    (21 working / 14 done / 11 idle / 6 waiting / 5 thinking / 1 error).
  • All 6 states screenshot-verified (assets/state-*.png).
  • Full state-machine demo wrap-demo.png: thinkingworking
    waitingworkingdone on a real bash npm test run.
  • Click-to-focus round-trip verified: from a Feishu tab, click the pill,
    focus jumps to the originating Windows Terminal tab (HWND consistent).
  • wrap-tool.ps1 exit-code semantics: 0 → done, 1 → waiting (default,
    configurable via -WaitingExitCodes), other → error.

npm run check result

Validator output for the hosted plugin directory:

OK   plugin antianqi/mcode-island

The 8 unrelated FAIL lines in npm run check are pre-existing on
upstream/main (other contributors' hosted plugins missing YAML
frontmatter); this PR does not touch them. The single npm test failure
(hosted-plugins.test.mjs:39) is a Windows-only path-separator mismatch
in the upstream test (plugins\alice\hello-world vs /plugins\/alice\/hello-world/)
and is unrelated to this PR.

Package contents

plugins/antianqi/mcode-island/
├── plugin.json                    # plugin manifest (Agent Plugins 1.0 schema)
├── 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/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

Limitations

  • Windows 10/11 only.
  • One widget per user session.
  • wrap-tool.ps1 only wraps bash; for read / write / edit the agent
    calls notify-island.ps1 directly.
  • No hover-expand, no token usage, no per-tool output yet (planned for v0.2,
    Tauri rewrite).

License

Apache-2.0.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

…ax Code agents

Adds a Skill-first plugin that surfaces the agent working state in a 320x60 WPF pill anchored to the top center of the primary display, so the user can leave the terminal in the background and still watch progress.

States: idle / thinking / working / waiting / done / error.

Includes wrap-tool.ps1, a thin bash wrapper that pushes working / done / error / waiting based on $LASTEXITCODE, so the user does not have to remember to call notify-island.ps1 for every shell command.
Adds a 1-second-polling daemon that reads the active mcode session messages.jsonl and infers the agent state (idle/thinking/working/done/error) without requiring the agent to call notify-island.ps1.

State mapping:

  role=user                  -> idle

  role=assistant + toolCall  -> working "<tool>: <args>"

  role=assistant + thinking  -> thinking

  role=assistant + text      -> idle (just replied)

  role=toolResult + !isError -> done "<tool> 完成"

  role=toolResult + isError  -> error "<tool> 失败"

  mcode 进程不在              -> error "mcode 进程已退出"

  60s 无新事件                -> idle 兑底

Priority logic: agent-pushed states (with Message) are preserved; detector takes over only for settle states (idle / error).

Tested on Windows 11 24H2 + PowerShell 5.1 against a live mcode session. All 6 state transitions verified, including mcode exit and recovery.

@hetaoBackend hetaoBackend left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review result: do not approve / do not merge yet.

The repository check passes (27 tests), but the core Windows detector is not ready:

  • mcode-status-detect.ps1:79-80 hard-codes C:\Users\Administrator\... and scans messages.jsonl, while the repository runtime uses resolved data directories and ledger.jsonl. Ordinary installations therefore report mcode 已退出 instead of detecting state.
  • mcode-status-detect.ps1:117-118,228-243 returns no message when the file mtime is unchanged, so the advertised 60-second idle fallback is unreachable during inactivity.
  • start-island.ps1:15-22, stop-island.ps1, and the detector start/stop scripts trust stale PID files and can refuse startup or Stop-Process -Force an unrelated process after PID reuse. Validate executable/command-line identity before acting.
  • wrap-tool.ps1:47-56 advertises a bash wrapper but executes -Command with Invoke-Expression as PowerShell code, creating an injection/shell-semantics boundary that should be removed or explicitly documented.
  • The quick-start commands are invalid PowerShell: README.md:82-90 uses %PLUGIN_DIR% and -Enable, but autostart.ps1:7-10 only supports -Action Enable.
  • start-island.ps1:40-44 waits for about to ShowDialog, a log message the widget never emits, so readiness is always reported as waiting.

Please fix the detector data-path/session contract, idle logic, PID validation, and launch/docs issues before requesting another review.

Fixes for review comments from hetaoBackend (commit fce7c5f):

  MiniMax-AI#1 detector hard-coded path: resolve the [userprofile]/.minimax-code
     directory at runtime via the mcode node process cmdline (regex on
     @minimax-ai/code/cli.js), with fallbacks to $env:USERPROFILE/.minimax-code,
     $env:APPDATA/minimax-code, and the current working directory.
     Override with -Root [path].

  MiniMax-AI#2 idle fallback unreachable: mtime cache now returns the last inferred
     message instead of null, so the 60s stale -> idle branch fires every
     poll. Verified locally: idle :: already idle 195s after 65s of inactivity.

  #2b session log: prefer ledger.jsonl (mcode v2 event stream) and fall
     back to messages.jsonl when ledger is missing. Both formats are handled
     in Infer-State (kind/phase for ledger, message.role for messages).

  MiniMax-AI#3 PID reuse safety: start/stop-{island,detect-island}.ps1 now verify
     the target PID command line contains the expected script path before
     acting. Stale PIDs and PID-reused processes are refused with a
     REFUSED log line instead of being killed.

  MiniMax-AI#4 wrap-tool.ps1 shell-injection: removed Invoke-Expression entirely.
     The wrapper is now status-only; the agent runs the command via mcode's
     own bash tool and passes -ExitCode to publish the outcome.
     Documented in README + SKILL.md.

  MiniMax-AI#5 README: -Enable -> -Action Enable to match autostart.ps1 parameter set.

  MiniMax-AI#6 start-island.ps1 readiness: dropped the 'about to ShowDialog' log wait
     (which was never emitted). Now polls MainWindowHandle != 0 every 500ms
     for up to 8s.

Tests: validator reports OK plugin antianqi/mcode-island. wrap-tool
6-state matrix verified locally (working / done / waiting / error).
@antianqi

Copy link
Copy Markdown
Author

Thanks for the review. Pushed bad0868 with fixes for all six items. Quick recap:

Code fixes

Local verification

  • node scripts/validate.mjs reports OK plugin antianqi/mcode-island.
  • wrap-tool.ps1 4-state matrix exercised locally: -ExitCode 0 → done, 1 → waiting, 2 → error, omitted → working.
  • detector v0.2.1 settles into idle :: 已静默 195s after 65s with no new events.

Not in this push (out of scope of the review)

  • Detector still polls every 1s. Worker-thread leak (PS 5.1 + pipeline cmdlets) is observed; documenting separately, planning a FileSystemWatcher-based replacement in v0.3.
  • No change to the public Skill surface area (still one Skill, mcode-island).

Ready for another pass.

antianqi added a commit to antianqi/mcode-island that referenced this pull request Aug 22, 2026
This standalone mirror is now in lockstep with the in-flight PR #17
(MiniMax-AI/MiniMax-Code-Plugins#17), commit bad0868.

Changes since v0.1.0:

  + mcode-status-detect.ps1     v0.2 detector daemon
  + start-detect-island.ps1
  + stop-detect-island.ps1
  + status-detect-island.ps1
  M README.md                    detector + wrap-tool new API + -Action Enable
  M mcode-island.cmd             detect-on / detect-off / detect-status subcommands
  M skills/mcode-island/SKILL.md detector + new wrap-tool two-step pattern
  M start-island.ps1             PID + cmdline check; readiness via MainWindowHandle
  M stop-island.ps1              PID + cmdline check (refuse on PID reuse)
  M wrap-tool.ps1                removed Invoke-Expression; status-only; -ExitCode arg

Detector resolves the mcode install root at startup by regexing the mcode
node process command line (matched on @minimax-ai/code/cli.js), with
fallbacks to $env:USERPROFILE/.minimax-code, $env:APPDATA/minimax-code, and
the current working directory.

Validator: OK plugin antianqi/mcode-island (same as PR #17 head).
… messages)

Get-LatestSessionFile always preferred ledger.jsonl when present, regardless
of which file was more recently written. On systems where mcode v0.2.x left
behind a stale ledger.jsonl from a previous session, the detector would
read the old ledger every poll, the 60s idle-fallback would fire against
an ancient mtime, and the widget would stay stuck on "已静默 NNNNNs"
forever (verified: 49549s = 13.76h against a ledger that was actually
{"action":"test ledger 1"} test residue).

Fix: compare mtimes and pick whichever is newer. Fall back to ledger if
messages is absent (original fallback contract), but never let a stale
ledger shadow a live messages.jsonl.

Triggered by PR MiniMax-AI#17 review testing: 9 hours of "idle :: 已静默 49549s"
on a fresh detector after the v0.2.1 fixes were deployed.
@antianqi

Copy link
Copy Markdown
Author

One more fix on top of the v0.2.1 review fixes (commit 6e99c0b).

Bug: Get-LatestSessionFile always preferred ledger.jsonl when present, regardless of which file was more recently written. mcode v0.2.x leaves a ledger.jsonl from each session around, and once the session ends the ledger stops being written but the file remains. With even one stale ledger lying around:

  1. The detector reads the stale ledger every poll (newer messages.jsonl is ignored).
  2. The stale ledger's LastWriteTime is hours/days old.
  3. The 60s idle fallback fires: $inferred = @{ state='idle'; message="已静默 Ns" } with N = seconds since the ancient mtime.
  4. The widget stays frozen on "idle :: 已静默 49549s" indefinitely.

Verified in real use: the v0.2.1 detector was running for 9 hours showing exactly that — 49549s matched a 13.76h-old ledger whose last line was {"action":"test ledger 1"} test residue from earlier debugging.

Fix: compare LastWriteTime and pick whichever is newer. Fall back to ledger only if messages is absent (preserves the original fallback contract for systems where only ledger exists). Never let a stale ledger shadow a live messages.jsonl.

-  # 优先 ledger.jsonl 最新的;如果同 session 有 ledger 就用 ledger
-  $ledger = $all | Where-Object { $_.Name -eq $FNAME_LEDGER } | Sort-Object LastWriteTime -Descending | Select-Object -First 1
-  if ($ledger) { return $ledger }
-  $msgs = $all | Where-Object { $_.Name -eq $FNAME_MESSAGES } | Sort-Object LastWriteTime -Descending | Select-Object -First 1
-  return $msgs
+  # mcode v0.2.x writes messages.jsonl live; ledger.jsonl is best-effort and may
+  # be left behind by an old session. Always pick whichever file is most
+  # recently touched, otherwise a stale ledger would dominate and the
+  # 60s-idle fallback would fire against ancient timestamps.
+  $ledger = $all | Where-Object { $_.Name -eq $FNAME_LEDGER } | Sort-Object LastWriteTime -Descending | Select-Object -First 1
+  $msgs   = $all | Where-Object { $_.Name -eq $FNAME_MESSAGES } | Sort-Object LastWriteTime -Descending | Select-Object -First 1
+  if ($ledger -and $msgs) {
+    if ($ledger.LastWriteTime -ge $msgs.LastWriteTime) { return $ledger }
+    return $msgs
+  }
+  if ($ledger) { return $ledger }
+  if ($msgs) { return $msgs }
+  return $null

Verified live: after restart the detector now flips through working (matched a live bash toolCall with the actual command as message) and done correctly, instead of being stuck on the 13.76h phantom idle.

Pushed as 6e99c0b on add-mcode-island.

notify-island.ps1 was writing status.json with only {state, message,
progress, ts} and no source field. The detector's takeover logic keys
off `cur.source -eq 'detector'` to decide whether the live entry is its
own or an externally-pushed one. With no source field on agent-pushed
states, the detector treated every agent push as "no current status" and
immediately overwrote it with whatever it had just inferred — most often
idle (60s fallback), even when the agent had just pushed `working` or
`thinking`.

Concretely: pushing `notify-island.ps1 -State working` would survive for
roughly 1 second before the detector's next poll clobbered it back to
idle. This made the manual notify tool useless for any state the detector
cares about, and made the `wrap-tool.ps1 -State working` wrap pattern
invisible on the pill.

Fix: add `source = 'agent'` to the payload. With it set, the detector's
existing precedence rules work as documented:

- agent push of working/thinking/done → preserved (not overwritten by
  the same-state detector inference, since detector-inferred
  working/thinking/done is not "settled" and does not trigger the
  takeover branch when the current entry is not the detector's own);
- agent push of idle/error → can be taken over by detector's
  idle/error inference, matching the original "detector settles agent"
  contract.

Verified live: `notify-island.ps1 -State thinking` now persists across
multiple detector polls (ts unchanged after 3.5s, message intact,
source field present).

Pushed on top of 6e99c0b on add-mcode-island.
@antianqi

Copy link
Copy Markdown
Author

One more on top of 6e99c0b — commit db73c11.

Bug: notify-island.ps1 was writing status.json with only {state, message, progress, ts} and no source field. The detector's takeover logic keys off cur.source -eq 'detector' to decide whether the live entry is its own or externally-pushed. With no source on agent-pushed states, the detector treated every agent push as "no current status" and immediately overwrote it — most often with idle (60s fallback), even when the agent had just pushed working or thinking.

Concretely: notify-island.ps1 -State working would survive for ~1 second before the detector's next poll clobbered it back to idle. The manual notify tool was useless for any state the detector cared about, and the wrap-tool.ps1 -State working wrap pattern would never show on the pill.

Fix: add source = 'agent' to the payload. With it, the detector's existing precedence rules work as documented:

  • agent push of working/thinking/done → preserved (detector-inferred working/thinking/done is not "settled", so the takeover branch is not taken when the current entry is not the detector's own);
  • agent push of idle/error → can be taken over by detector-inferred idle/error, matching the original "detector settles agent" contract.

Verified live: notify-island.ps1 -State thinking now persists across multiple detector polls — ts unchanged after 3.5s, message intact, source field present in the JSON.

 $payload = [PSCustomObject]@{
   state    = $State
   message  = $Message
   progress = $Progress
   ts       = $ts
+  source   = 'agent'
 } | ConvertTo-Json -Compress

So on top of the v0.2.1 review fixes there are now two late commits:

  • 6e99c0bGet-LatestSessionFile mtime-aware ledger vs messages pick
  • db73c11notify-island.ps1 tags its writes with source='agent'

The detector polled once per second, and every poll walked ~15 pipeline
cmdlets: Get-ChildItem -Recurse | Where-Object | Sort-Object |
Select-Object (×2), Get-Content -Raw | ConvertFrom-Json (×3-4),
$collection | Where-Object (×3), Get-Process (×1-2), etc. PS 5.1 hidden
window has a known issue where completed pipeline tasks aren't
immediately released back to the Runspace thread pool — the pool backs
up over multi-hour runs. After ~9 hours of polling, the process was
holding ~30k threads and Get-ChildItem was effectively starved:
status.json stopped updating, island.log stopped appending, the
process looked alive but the loop was no longer advancing. Only a
restart recovered it.

Fix in three layers:

1. Replace the most expensive pipeline calls with direct .NET method
   calls so no Runspace hop is incurred:
   - Get-LatestSessionFile: Get-ChildItem -Recurse | Where-Object |
     Sort-Object | Select-Object  →  a single
     [System.IO.Directory]::EnumerateFiles + manual mtime scan
   - Get-McodePid: Get-ChildItem | foreach { Get-Content |
     ConvertFrom-Json | Get-Process }  →  EnumerateFiles + File.ReadAllText
     + Process.GetProcessById
   - Read-LastMessage: Get-Item  →  [System.IO.FileInfo]::new(...)
   - Read-StatusObj: Get-Content -Raw  →  File.ReadAllText
   - Infer-State (assistant branch): $m.content | Where-Object ×3  →
     one foreach loop with early exit (toolCall wins, no need to scan
     the rest)

2. Add a 5s TTL cache for both `mcodePid` and `latestSessionFilePath`
   in the main loop. mcode doesn't churn sub-second, and a fresh
   session log only shows up when mcode itself starts a new session,
   which is also a sub-5s event in practice. 5s is a comfortable
   upper bound that cuts the heavy directory enumeration to once per
   5s without losing visible state fidelity (the existing mtime gate
   in Read-LastMessage already gates re-parse on real content
   changes, so cache staleness is invisible to the user).

3. Verified live: after the fix, restarting the detector and running
   for 30s reports 18-28 threads (was previously climbing into the
   thousands within minutes). State transitions (working → done →
   working) still fire correctly. The 60s-idle fallback still fires
   correctly.

Side benefit: the refactor also fixes a tiny correctness wart in
Get-McodePid — when multiple .json files happen to coexist in
.mcode-active (e.g. during a restart overlap), the previous code
returned the first hit; the new code picks the most-recently-touched
one, which matches what Get-LatestSessionFile does on the messages
side.

Pushed on top of db73c11 on add-mcode-island.
@antianqi

Copy link
Copy Markdown
Author

One more on top of db73c11 — commit 1b8eae4.

Bug: the detector polled once per second and every poll walked ~15 pipeline cmdlets: Get-ChildItem -Recurse | Where-Object | Sort-Object | Select-Object (×2), Get-Content -Raw | ConvertFrom-Json (×3-4), $collection | Where-Object (×3), Get-Process (×1-2), etc. PS 5.1 hidden window has a known issue where completed pipeline tasks aren't immediately released back to the Runspace thread pool — the pool backs up over multi-hour runs. After ~9 hours of polling, the process was holding ~30k threads and Get-ChildItem was effectively starved: status.json stopped updating, island.log stopped appending, the process looked alive but the loop was no longer advancing. Only a restart recovered it.

(The user-visible symptom masked as "9 hours of idle :: 已静默 49549s" is the other bug from 6e99c0b; this commit fixes the underlying cause of why a fresh detector, after that fix, would have eventually re-frozen.)

Fix in three layers:

  1. Replace the most expensive pipeline calls with direct .NET method calls so no Runspace hop is incurred:

    • Get-LatestSessionFile: Get-ChildItem -Recurse | Where-Object | Sort-Object | Select-Object → a single [System.IO.Directory]::EnumerateFiles + manual mtime scan
    • Get-McodePid: Get-ChildItem | foreach { Get-Content | ConvertFrom-Json | Get-Process }EnumerateFiles + File.ReadAllText + Process.GetProcessById
    • Read-LastMessage: Get-Item[System.IO.FileInfo]::new(...)
    • Read-StatusObj: Get-Content -RawFile.ReadAllText
    • Infer-State (assistant branch): $m.content | Where-Object ×3 → one foreach loop with early exit (toolCall wins, no need to scan the rest)
  2. Add a 5s TTL cache for both mcodePid and latestSessionFilePath in the main loop. mcode doesn't churn sub-second, and a fresh session log only shows up when mcode itself starts a new session, which is also a sub-5s event in practice. 5s is a comfortable upper bound that cuts the heavy directory enumeration to once per 5s without losing visible state fidelity (the existing mtime gate in Read-LastMessage already gates re-parse on real content changes, so cache staleness is invisible to the user).

  3. Verified live: after the fix, restarting the detector and running for 30s reports 18-28 threads (was previously climbing into the thousands within minutes and the tens of thousands within hours). State transitions (workingdoneworking) still fire correctly. The 60s-idle fallback still fires correctly. No new code paths in the inference logic; all behavior is identical from the widget's perspective.

Side benefit: the refactor also fixes a tiny correctness wart in Get-McodePid — when multiple .json files happen to coexist in .mcode-active (e.g. during a restart overlap), the previous code returned the first hit; the new code picks the most-recently-touched one, which matches what Get-LatestSessionFile does on the messages side.

-  $candidates = Get-ChildItem -Path $mcodeActiveDir -Filter '*.json' -ErrorAction SilentlyContinue
-  foreach ($f in $candidates) {
-    $j = Get-Content $f.FullName -Raw | ConvertFrom-Json
-    $targetPid = [int]$j.pid
-    $proc = Get-Process -Id $targetPid -ErrorAction SilentlyContinue
+  foreach ($f in [System.IO.Directory]::EnumerateFiles($mcodeActiveDir, '*.json')) {
+    $raw = [System.IO.File]::ReadAllText($f)
+    $j = $raw | ConvertFrom-Json
+    $targetPid = [int]$j.pid
+    $proc = [System.Diagnostics.Process]::GetProcessById($targetPid)

So on top of the v0.2.1 review fixes there are now three late commits:

  • 6e99c0bGet-LatestSessionFile mtime-aware ledger vs messages pick
  • db73c11notify-island.ps1 tags its writes with source='agent'
  • 1b8eae4 — kill the pipeline-thread leak that would have eventually re-frozen any detector this plugin ships

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants