diff --git a/plugins/antianqi/mcode-island/README.md b/plugins/antianqi/mcode-island/README.md index 0f39645d..fd3a5817 100644 --- a/plugins/antianqi/mcode-island/README.md +++ b/plugins/antianqi/mcode-island/README.md @@ -156,6 +156,28 @@ alternative: & "%PLUGIN_DIR%\mcode-island\notify-island.ps1" -State error -Message "npm test failed" ``` +For sub-step progress (Computer Use iterative loops, multi-step plans), +pass `-Step` / `-Total` / `-Detail` so the pill shows what the agent is +doing *right now*: + +```powershell +& "%PLUGIN_DIR%\mcode-island\notify-island.ps1" -State working -Message "Computer Use" ` + -Step 3 -Total 12 -Detail "fill username field" +# → pill renders: "step 3/12 · fill username field" + +& "%PLUGIN_DIR%\mcode-island\notify-island.ps1" -State working -Message "Bash" ` + -Step 5 -Detail "npm install" +# → pill renders: "step 5 · npm install" + +& "%PLUGIN_DIR%\mcode-island\notify-island.ps1" -State done -Message "Bash ok" +# → pill renders: "Bash ok" (no step → legacy behavior, backward compat) +``` + +All three params are optional and backward compatible. The detail field +replaces the message in the rendered pill when present (avoids stacking +"Bash ok · fill username"). See `skills/mcode-island/SKILL.md` for the +full semantics and the contract with the widget renderer. + ## Quick start 1. **Install** — copy this folder into your `~/.minimax/plugins/mcode-island/` diff --git a/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/_lib.ps1 b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/_lib.ps1 index 797813ba..0990f5b4 100644 --- a/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/_lib.ps1 +++ b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/_lib.ps1 @@ -43,7 +43,10 @@ function Push-Island { [ValidateSet('idle','thinking','working','waiting','done','error')] [string]$State, - [string]$Message = '' + [string]$Message = '', + [int]$Step = -1, + [int]$Total = -1, + [string]$Detail = '' ) if (-not (Test-Path -LiteralPath $script:NotifyIsland)) { # Widget is not installed yet — silent no-op. The plugin's @@ -52,7 +55,7 @@ function Push-Island { return } try { - & $script:NotifyIsland -State $State -Message $Message 2>$null | Out-Null + & $script:NotifyIsland -State $State -Message $Message -Step $Step -Total $Total -Detail $Detail 2>$null | Out-Null } catch { # Hook must never block the agent on a notification failure. } @@ -97,6 +100,26 @@ function Format-ToolSummary { 'WebSearch' { $detail = [string]$Event.tool_input.query } 'Task' { $detail = [string]$Event.tool_input.description } 'NotebookEdit' { $detail = [string]$Event.tool_input.notebook_path } + # mcode-internal: Computer Use 抽 action + coordinate/text + # 例: "mcode-computer-use : click at (1024,768)" + # "mcode-computer-use : type 'hello'" + # 注意:coordinate 是 array,PowerShell 默认 $OFS=' ' 会让 + # "$coord" 渲染成 "(1024 768)" 不是 "(1024,768)"。必须 + # 显式 -join ','。' ' 在 pill 上看起来像数字被截断, + # 影响用户判断坐标。 + 'mcode-computer-use' { + $act = if ($Event.tool_input.action) { [string]$Event.tool_input.action } else { '' } + if ($Event.tool_input.coordinate) { + $coord = $Event.tool_input.coordinate + $coordStr = "($($coord -join ','))" + $detail = "$act at $coordStr" + } elseif ($Event.tool_input.text) { + $txt = [string]$Event.tool_input.text + $detail = "$act '$txt'" + } else { + $detail = $act + } + } default { $detail = '' } } } diff --git a/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/post-tool-use.ps1 b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/post-tool-use.ps1 index 034a31e5..be005f80 100644 --- a/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/post-tool-use.ps1 +++ b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/post-tool-use.ps1 @@ -4,6 +4,9 @@ # 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. +# Per-tool summary (Format-ToolSummary) is split into +# Message=" ok|failed" and Detail=, so the pill +# renders "Bash ok · ls -la /tmp" instead of just "Bash ok". . "$PSScriptRoot\_lib.ps1" $evt = Read-HookStdin if (Test-IsSelfPush $evt) { exit 0 } @@ -20,9 +23,18 @@ if ($null -eq $result) { elseif ($s -match '^\s*(Error|ERROR|✕|Error:|\[ERROR\])') { $isError = $true } } +# Format-ToolSummary 抽 detail,但要剥掉 "tool : " 前缀,只留后半段 +$summary = Format-ToolSummary $evt +$detail = '' +if ($summary -and $summary.StartsWith("$tool : ")) { + $detail = $summary.Substring($tool.Length + 3) +} elseif ($summary -and $summary -ne $tool) { + $detail = $summary +} + if ($isError) { - Push-Island -State error -Message "$tool failed" + Push-Island -State error -Message "$tool failed" -Detail $detail } else { - Push-Island -State done -Message "$tool ok" + Push-Island -State done -Message "$tool ok" -Detail $detail } exit 0 diff --git a/plugins/antianqi/mcode-island/mcode-island.ps1 b/plugins/antianqi/mcode-island/mcode-island.ps1 index 898322a7..842d3a82 100644 --- a/plugins/antianqi/mcode-island/mcode-island.ps1 +++ b/plugins/antianqi/mcode-island/mcode-island.ps1 @@ -370,6 +370,34 @@ function Stop-IndeterminateShimmer { $script:progressShimmerTransform.X = -130 # 重置到起点 } +# Sub-step 渲染("step N[/M] · detail"): +# Step > 0 + Total > 0 → "step 3/12 · fill username" +# Step > 0 + Total <= 0 → "step 3 · fill username" +# Step > 0 + Detail 空 → "step 3/12" +# Step <= 0 → 原 Message 字段 +# 这样 message 字段保持"工具名"("Bash ok"),detail 字段填具体动作, +# 渲染时拼成 "step 3/12 · Bash ok · fill username" 或者更精确的 +# "step 3/12 · fill username"(detail 存在时优先覆盖 message)。 +# 注意:detail 非空时**完全替换** message,避免双重信息("Bash ok · ls -la")。 +function Build-DisplayMessage { + param( + [string]$Message, + [int]$Step, + [int]$Total, + [string]$Detail + ) + $base = if ($Message) { $Message } else { '' } + if ($Step -le 0) { return $base } + + $stepStr = if ($Total -gt 0) { "step $Step/$Total" } else { "step $Step" } + if ($Detail) { + # detail 非空时优先用 detail(agent 已经表达了"我在做什么") + return "$stepStr · $Detail" + } + # detail 空但 step 给出 → 只显示 step,避免重复 message 造成噪声 + return $stepStr +} + # 状态更新 # Progress 取值约定(跟 notify-island.ps1 / detector 对齐): # -1 → 没有进度信息,进度条隐藏 @@ -377,6 +405,14 @@ function Stop-IndeterminateShimmer { # Usage5h:剩余百分比(0..100);-2 = 未提供 # Usage5hResetMs:距下次 5h 刷新的毫秒数;0 = 未知 # TodoProgress:todowrite 列表的完成百分比(0..100);-2 = 未提供 +# Step/Total/Detail:sub-step 进度(agent 自报),参 Build-DisplayMessage +# - 优先级:显式 Progress > TodoProgress > shimmer +# - 即:agent 直接传 progress 最高;否则如果有 todo 列表就用 todo 完成度;都没就 shimmer 动画 +# -1 → 没有进度信息,进度条隐藏 +# 0..100 → 百分比,0=空条,100=满条;超出范围会被 clamp +# Usage5h:剩余百分比(0..100);-2 = 未提供 +# Usage5hResetMs:距下次 5h 刷新的毫秒数;0 = 未知 +# TodoProgress:todowrite 列表的完成百分比(0..100);-2 = 未提供 # - 优先级:显式 Progress > TodoProgress > shimmer # - 即:agent 直接传 progress 最高;否则如果有 todo 列表就用 todo 完成度;都没就 shimmer 动画 function Update-State { @@ -386,14 +422,17 @@ function Update-State { [int]$Progress = -1, [int]$Usage5h = -2, [int]$Usage5hResetMs = 0, - [int]$TodoProgress = -2 + [int]$TodoProgress = -2, + [int]$Step = -1, + [int]$Total = -1, + [string]$Detail = '' ) $s = $script:stateMap[$State] if (!$s) { $s = $script:stateMap['idle'] } $script:statusDot.Fill = C $s.dot $script:pulseRing.Fill = C $s.ring $script:stateText.Text = $s.label - $script:messageText.Text = if ($Message) { $Message } else { '' } + $script:messageText.Text = Build-DisplayMessage -Message $Message -Step $Step -Total $Total -Detail $Detail $script:actionIcon.Text = $s.icon if ($State -in @('thinking','working','waiting')) { Start-Pulse } else { Stop-Pulse } @@ -689,18 +728,25 @@ $timer.Add_Tick({ $script:lastStatusMtime = $mtime $data = Get-Content $statusFile -Raw -Encoding UTF8 | ConvertFrom-Json # progress 也要进 sig,否则 agent 连续推 working+相同 message+不同 progress 会被去重 + # step/total/detail 也要进 sig,否则连续推同 state 但不同 step 会被去重 $prog = if ($data.PSObject.Properties['progress']) { [int]$data.progress } else { -1 } $usage = $null $resetMs = 0 $todoP = -2 + $step = -1 + $total = -1 + $detail = '' if ($data.PSObject.Properties['usage5h'] -and $null -ne $data.usage5h) { $usage = [int]$data.usage5h } if ($data.PSObject.Properties['usage5hResetMs'] -and $null -ne $data.usage5hResetMs) { $resetMs = [int]$data.usage5hResetMs } if ($data.PSObject.Properties['todoProgress'] -and $null -ne $data.todoProgress) { $todoP = [int]$data.todoProgress } - $sig = "$($data.state)|$($data.message)|$prog|$usage|$resetMs|$todoP|$($data.ts)" + if ($data.PSObject.Properties['step'] -and $null -ne $data.step) { $step = [int]$data.step } + if ($data.PSObject.Properties['total'] -and $null -ne $data.total) { $total = [int]$data.total } + if ($data.PSObject.Properties['detail'] -and $null -ne $data.detail) { $detail = [string]$data.detail } + $sig = "$($data.state)|$($data.message)|$prog|$usage|$resetMs|$todoP|$step|$total|$detail|$($data.ts)" if ($sig -eq $script:lastStatusSig) { return } $script:lastStatusSig = $sig - Dbg "POLL: $($data.state) :: $($data.message) (progress=$prog usage5h=$usage resetMs=$resetMs todoProgress=$todoP)" - Update-State -State $data.state -Message $data.message -Progress $prog -Usage5h $usage -Usage5hResetMs $resetMs -TodoProgress $todoP + Dbg "POLL: $($data.state) :: $($data.message) step=$step/$total detail=$detail (progress=$prog usage5h=$usage resetMs=$resetMs todoProgress=$todoP)" + Update-State -State $data.state -Message $data.message -Progress $prog -Usage5h $usage -Usage5hResetMs $resetMs -TodoProgress $todoP -Step $step -Total $total -Detail $detail } catch { Dbg "POLL ERR: $($_.Exception.Message)" } @@ -717,12 +763,18 @@ if (Test-Path $statusFile) { $initUsage = $null $initReset = 0 $initTodo = -2 + $initStep = -1 + $initTotal = -1 + $initDetail = '' if ($init.PSObject.Properties['usage5h'] -and $null -ne $init.usage5h) { $initUsage = [int]$init.usage5h } if ($init.PSObject.Properties['usage5hResetMs'] -and $null -ne $init.usage5hResetMs) { $initReset = [int]$init.usage5hResetMs } if ($init.PSObject.Properties['todoProgress'] -and $null -ne $init.todoProgress) { $initTodo = [int]$init.todoProgress } - $script:lastStatusSig = "$($init.state)|$($init.message)|$initProg|$initUsage|$initReset|$initTodo|$($init.ts)" + if ($init.PSObject.Properties['step'] -and $null -ne $init.step) { $initStep = [int]$init.step } + if ($init.PSObject.Properties['total'] -and $null -ne $init.total) { $initTotal = [int]$init.total } + if ($init.PSObject.Properties['detail'] -and $null -ne $init.detail) { $initDetail = [string]$init.detail } + $script:lastStatusSig = "$($init.state)|$($init.message)|$initProg|$initUsage|$initReset|$initTodo|$initStep|$initTotal|$initDetail|$($init.ts)" $script:lastStatusMtime = (Get-Item $statusFile).LastWriteTimeUtc.Ticks - Update-State -State $init.state -Message $init.message -Progress $initProg -Usage5h $initUsage -Usage5hResetMs $initReset -TodoProgress $initTodo + Update-State -State $init.state -Message $init.message -Progress $initProg -Usage5h $initUsage -Usage5hResetMs $initReset -TodoProgress $initTodo -Step $initStep -Total $initTotal -Detail $initDetail } catch {} } else { Update-State -State 'idle' -Message '' diff --git a/plugins/antianqi/mcode-island/notify-island.ps1 b/plugins/antianqi/mcode-island/notify-island.ps1 index f20451e7..1b9f7d71 100644 --- a/plugins/antianqi/mcode-island/notify-island.ps1 +++ b/plugins/antianqi/mcode-island/notify-island.ps1 @@ -10,7 +10,10 @@ param( [ValidateSet('idle','thinking','working','waiting','done','error')] [string]$State = 'idle', [string]$Message = '', - [int]$Progress = -1 + [int]$Progress = -1, + [int]$Step = -1, + [int]$Total = -1, + [string]$Detail = '' ) $ErrorActionPreference = 'Stop' @@ -113,6 +116,9 @@ $payload = [PSCustomObject]@{ state = $State message = $Message progress = $Progress + step = $Step + total = $Total + detail = $Detail ts = $ts source = 'agent' } | ConvertTo-Json -Compress diff --git a/plugins/antianqi/mcode-island/plugin.json b/plugins/antianqi/mcode-island/plugin.json index f39491dc..cc3c26f8 100644 --- a/plugins/antianqi/mcode-island/plugin.json +++ b/plugins/antianqi/mcode-island/plugin.json @@ -1,8 +1,8 @@ { "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "mcode-island", - "version": "0.3.0", - "description": "Windows 桌面灵动岛 (Dynamic Island) 状态窗口:让 mcode agent 把工作状态(idle/thinking/working/waiting/done/error)实时推送到屏幕顶部悬浮 pill,agent 自己忙的时候用户不用切回 mcode 也能看到进度。v0.3.0 增加 io.minimax.mcode 客户端扩展(Hooks 草案),与 MiniMax-Code-Plugins PR #20 的 portable Hooks 提案对齐;mcode 0.2.4+ Runtime 触发,registry 接受后零改动生效。", + "version": "0.4.0", + "description": "Windows 桌面灵动岛 (Dynamic Island) 状态窗口:让 mcode agent 把工作状态(idle/thinking/working/waiting/done/error)实时推送到屏幕顶部悬浮 pill,agent 自己忙的时候用户不用切回 mcode 也能看到进度。v0.4.0 扩展 status.json schema 增加 step/total/detail 三个字段并更新 widget 渲染:agent 现在可以推送 'step 3/12 · fill username field' 这类 sub-step 进度,Computer Use 多步循环、长任务分阶段展示。Backward compat:所有新字段 optional。v0.3.0 增加 io.minimax.mcode 客户端扩展(Hooks 草案),与 MiniMax-Code-Plugins PR #20 的 portable Hooks 提案对齐;mcode 0.2.4+ Runtime 触发,registry 接受后零改动生效。", "author": { "name": "antianqi", "url": "https://github.com/antianqi" diff --git a/plugins/antianqi/mcode-island/scripts/smoke.mjs b/plugins/antianqi/mcode-island/scripts/smoke.mjs index c3c1bd5e..01884410 100644 --- a/plugins/antianqi/mcode-island/scripts/smoke.mjs +++ b/plugins/antianqi/mcode-island/scripts/smoke.mjs @@ -155,7 +155,7 @@ const checkEntry = async (event, entry) => { }; const main = async () => { - console.log(`mcode-island v0.3.0 self-check`); + console.log(`mcode-island v0.4.0 self-check`); console.log(`plugin root: ${PLUGIN_ROOT}`); console.log('-'.repeat(60)); @@ -182,8 +182,8 @@ const main = async () => { } else { out('PASS', `plugin.json: name is "${plugin.name}"`); } - if (plugin.version !== '0.3.0') { - out('FAIL', `plugin.json: version is "${plugin.version}", expected "0.3.0"`); + if (plugin.version !== '0.4.0') { + out('FAIL', `plugin.json: version is "${plugin.version}", expected "0.4.0"`); } else { out('PASS', `plugin.json: version is "${plugin.version}"`); } @@ -311,6 +311,58 @@ const main = async () => { out('PASS', '_lib.ps1: shared helper present'); } + // 5c1. Sub-step progress extension (round-13 refactor). + // notify-island.ps1 must accept -Step/-Total/-Detail and write them + // into status.json. mcode-island.ps1 widget must define + // Build-DisplayMessage and pass step/total/detail to it. + // _lib.ps1 Format-ToolSummary must extract mcode-computer-use + // action+coordinate; Push-Island must forward the new fields. + // The detailed functional tests live in test-substep-progress.mjs; + // here we lock the surface contract so a future refactor that + // drops the params surfaces in smoke (fast path) before reaching + // the slower pwsh-spawned tests. + const substepNotifyPath = join(PLUGIN_ROOT, 'notify-island.ps1'); + const substepWidgetPath = join(PLUGIN_ROOT, 'mcode-island.ps1'); + if (!(await exists(substepNotifyPath))) { + out('FAIL', 'notify-island.ps1 missing (sub-step lock skipped)'); + } else { + const notify = await readFile(substepNotifyPath, 'utf8'); + if (!/\[int\]\$Step\s*=\s*-1/.test(notify) || + !/\[int\]\$Total\s*=\s*-1/.test(notify) || + !/\[string\]\$Detail\s*=\s*''/.test(notify)) { + out('FAIL', 'notify-island.ps1: missing -Step/-Total/-Detail params'); + } else { + out('PASS', 'notify-island.ps1: declares -Step -Total -Detail'); + } + if (!/step\s*=\s*\$Step/.test(notify) || + !/total\s*=\s*\$Total/.test(notify) || + !/detail\s*=\s*\$Detail/.test(notify)) { + out('FAIL', 'notify-island.ps1: status.json payload missing step/total/detail fields'); + } else { + out('PASS', 'notify-island.ps1: writes step/total/detail to status.json'); + } + } + if (await exists(substepWidgetPath)) { + const widget = await readFile(substepWidgetPath, 'utf8'); + if (!/function Build-DisplayMessage/.test(widget)) { + out('FAIL', 'mcode-island.ps1: Build-DisplayMessage function missing'); + } else { + out('PASS', 'mcode-island.ps1: Build-DisplayMessage function present'); + } + if (!/\[int\]\$Step\s*=\s*-1/.test(widget) || + !/\[int\]\$Total\s*=\s*-1/.test(widget)) { + out('FAIL', 'mcode-island.ps1: Update-State missing Step/Total params'); + } else { + out('PASS', 'mcode-island.ps1: Update-State accepts Step/Total/Detail'); + } + } + const substepTestPath = join(PLUGIN_ROOT, 'scripts', 'test-substep-progress.mjs'); + if (!(await exists(substepTestPath))) { + out('WARN', 'scripts/test-substep-progress.mjs missing (sub-step detailed tests not run)'); + } else { + out('PASS', 'scripts/test-substep-progress.mjs exists (run separately for full suite)'); + } + // 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` diff --git a/plugins/antianqi/mcode-island/scripts/test-substep-progress.mjs b/plugins/antianqi/mcode-island/scripts/test-substep-progress.mjs new file mode 100644 index 00000000..25a06fd2 --- /dev/null +++ b/plugins/antianqi/mcode-island/scripts/test-substep-progress.mjs @@ -0,0 +1,369 @@ +#!/usr/bin/env node +// mcode-island v0.4.x — negative-first self-check for the sub-step +// progress extension (status.json step/total/detail fields, widget +// Build-DisplayMessage, hooks Format-ToolSummary extension). +// +// Cross-platform (Windows / macOS / Linux). No deps beyond Node >= 18. +// +// Exit 0 on full pass, 1 on any failure. Per-check line prints +// PASS / FAIL with the failing expectation. +// +// Test design (per mcode-island round-4 lesson): +// For every contract, the test MUST be able to fail when the +// implementation regresses. We achieve this by: +// 1. Asserting the *observable* contract (status.json shape, +// PowerShell function output) not the internal call graph +// 2. Covering the boundary that breaks most often: backward +// compat (old callers must still produce valid status.json) + +import { spawn } from 'node:child_process'; +import { mkdir, writeFile, readFile, rm, stat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { strict as assert } from 'node:assert'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const PLUGIN_ROOT = resolve(__dirname, '..'); +const NOTIFY_ISLAND = join(PLUGIN_ROOT, 'notify-island.ps1'); +const WIDGET = join(PLUGIN_ROOT, 'mcode-island.ps1'); +const LIB = join(PLUGIN_ROOT, 'io.minimax.mcode', 'hooks', 'scripts', '_lib.ps1'); + +// Cross-platform pwsh lookup: prefer pwsh7 install in $HOME, fall back +// to PATH-resolved `pwsh`. We do NOT hardcode C:\ paths in production +// (the smoke.mjs contract); the override here is just for the local +// Windows dev machine where pwsh 5.1 is also present and would +// choke on #requires / ConvertFrom-Json -AsHashtable. +function findPwsh() { + if (process.platform === 'win32') { + const home = process.env.USERPROFILE || process.env.HOME || ''; + const candidates = [ + join(home, 'pwsh7_6', 'pwsh.exe'), + join(home, 'pwsh', 'pwsh.exe'), + ]; + return candidates[0]; // best-effort; if missing, spawn falls back to PATH + } + return 'pwsh'; +} + +const PWSH = findPwsh(); + +let pass = 0, fail = 0; +const out = (tag, msg) => { + const sym = { PASS: 'OK ', FAIL: 'FAIL' }[tag]; + console.log(`[${sym}] ${msg}`); + if (tag === 'PASS') pass++; else fail++; +}; + +const exists = async (p) => { + try { await stat(p); return true; } catch { return false; } +}; + +// Spawn pwsh with a custom $env:APPDATA so we don't disturb the +// real %APPDATA%\mcode-island\ the widget is reading. +async function runPwsh(script, extraEnv = {}) { + const tmpAppData = join(tmpdir(), `mcode-island-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`); + await mkdir(join(tmpAppData, 'mcode-island'), { recursive: true }); + return new Promise((resolveP, rejectP) => { + const child = spawn(PWSH, ['-NoProfile', '-Command', script], { + env: { + ...process.env, + APPDATA: tmpAppData, + ...extraEnv, + }, + }); + let stdout = '', stderr = ''; + child.stdout.on('data', (d) => stdout += d); + child.stderr.on('data', (d) => stderr += d); + child.on('close', (code) => resolveP({ stdout, stderr, code, tmpAppData })); + child.on('error', rejectP); + }); +} + +async function readStatusJsonOf(tmpAppData) { + // notify-island.ps1 writes via [System.IO.File]::WriteAllText(.., UTF8). + // On Windows PowerShell 5.1 / .NET Framework that emits a UTF-8 BOM + // (\ufeff). PowerShell's Get-Content -Encoding UTF8 strips it, but + // Node's strict JSON.parse doesn't. We strip here so the test sees + // what the widget sees. + let raw = await readFile(join(tmpAppData, 'mcode-island', 'status.json'), 'utf8'); + if (raw.charCodeAt(0) === 0xFEFF) raw = raw.slice(1); + return raw; +} + +// --------------------------------------------------------------------------- +// 1. notify-island.ps1 schema — round-trip +// --------------------------------------------------------------------------- + +async function testNotifySchema() { + console.log('-'.repeat(60)); + console.log('1. notify-island.ps1 schema round-trip'); + + // 1a. all three new fields + { + const r = await runPwsh(`& "${NOTIFY_ISLAND}" -State working -Message 'X' -Step 3 -Total 12 -Detail 'fill username'`); + assert.equal(r.code, 0, `notify-island exited ${r.code}: ${r.stderr}`); + const j = JSON.parse(await readStatusJsonOf(r.tmpAppData)); + assert.equal(j.step, 3, 'step must round-trip'); + assert.equal(j.total, 12, 'total must round-trip'); + assert.equal(j.detail, 'fill username', 'detail must round-trip'); + out('PASS', 'all three new fields round-trip with explicit values'); + } + + // 1b. step without total + { + const r = await runPwsh(`& "${NOTIFY_ISLAND}" -State working -Message 'X' -Step 5 -Detail 'npm install'`); + const j = JSON.parse(await readStatusJsonOf(r.tmpAppData)); + assert.equal(j.step, 5); + assert.equal(j.total, -1, 'total stays at -1 when caller omits it'); + assert.equal(j.detail, 'npm install'); + out('PASS', 'step without total: step=5, total stays -1'); + } + + // 1c. backward compat — old callers don't pass new params + { + const r = await runPwsh(`& "${NOTIFY_ISLAND}" -State working -Message 'legacy call'`); + const j = JSON.parse(await readStatusJsonOf(r.tmpAppData)); + assert.equal(j.step, -1, 'backward compat: step must default to -1'); + assert.equal(j.total, -1, 'backward compat: total must default to -1'); + assert.equal(j.detail, '', 'backward compat: detail must default to empty string'); + assert.equal(j.message, 'legacy call', 'message preserved'); + assert.equal(j.state, 'working'); + // Negative-injection for this contract: imagine the + // implementation forgot to write step/total/detail at all — + // the JSON would lack these fields, and `j.step` would be + // `undefined`. The assert.equal above against -1 catches that. + out('PASS', 'backward compat: old callers produce step=-1, total=-1, detail=""'); + } + + // 1d. schema version invariants — these were already there + { + const r = await runPwsh(`& "${NOTIFY_ISLAND}" -State working -Message 'X'`); + const j = JSON.parse(await readStatusJsonOf(r.tmpAppData)); + for (const k of ['state', 'message', 'progress', 'ts', 'source']) { + assert.ok(k in j, `existing field ${k} must still be present`); + } + out('PASS', 'existing fields (state/message/progress/ts/source) preserved'); + } +} + +// --------------------------------------------------------------------------- +// 2. Build-DisplayMessage — PowerShell function unit +// --------------------------------------------------------------------------- + +async function testBuildDisplayMessage() { + console.log('-'.repeat(60)); + console.log('2. Build-DisplayMessage function contract'); + + // Source-out the function from mcode-island.ps1 by sourcing it in + // a no-window context, then asserting. mcode-island.ps1 starts WPF + // if you load it directly, so we extract just the function body + // by `Get-Content | Select-String` and re-define it for testing. + const widgetSrc = await readFile(WIDGET, 'utf8'); + const fnMatch = widgetSrc.match(/function Build-DisplayMessage\s*\{[\s\S]*?\n\}/); + assert.ok(fnMatch, 'Build-DisplayMessage function must exist in widget source'); + const fnBody = fnMatch[0]; + + const cases = [ + // [step, total, detail, message, expected] + [3, 12, 'fill username', 'Bash ok', 'step 3/12 · fill username'], + [5, -1, 'npm install', 'Bash', 'step 5 · npm install'], + [3, 12, '', 'Bash ok', 'step 3/12'], + [3, -1, '', 'Bash ok', 'step 3'], + [-1, -1, '', 'Bash ok', 'Bash ok'], + [-1, -1, 'orphan', 'Bash ok', 'Bash ok'], // no step → ignore detail + [3, 0, 'edge', 'Bash ok', 'step 3 · edge'], // total=0 → no /N + [3, 12, 'detail wins', '', 'step 3/12 · detail wins'], // empty message + [3, 12, 'd', 'm', 'step 3/12 · d'], // detail replaces message + ]; + + for (const [step, total, detail, message, expected] of cases) { + const script = ` +${fnBody} +$r = Build-DisplayMessage -Message ${JSON.stringify(message)} -Step ${step} -Total ${total} -Detail ${JSON.stringify(detail)} +Write-Output $r +`; + const r = await runPwsh(script); + const got = r.stdout.trim(); + if (got === expected) { + out('PASS', `Build-DisplayMessage(${step},${total},"${detail}","${message}") = "${expected}"`); + } else { + out('FAIL', `Build-DisplayMessage(${step},${total},"${detail}","${message}"): expected "${expected}", got "${got}"`); + } + } +} + +// --------------------------------------------------------------------------- +// 3. Format-ToolSummary — mcode-computer-use case +// --------------------------------------------------------------------------- + +async function testFormatToolSummaryCU() { + console.log('-'.repeat(60)); + console.log('3. Format-ToolSummary for mcode-computer-use'); + + const libSrc = await readFile(LIB, 'utf8'); + const fnMatch = libSrc.match(/function Format-ToolSummary\s*\{[\s\S]*?\n\}/); + assert.ok(fnMatch, 'Format-ToolSummary must exist in _lib.ps1'); + const fnBody = fnMatch[0]; + + const cases = [ + // input event JSON, expected output + [{ tool_name: 'mcode-computer-use', tool_input: { action: 'click', coordinate: [1024, 768] } }, + 'mcode-computer-use : click at (1024,768)'], + [{ tool_name: 'mcode-computer-use', tool_input: { action: 'type', text: 'hello' } }, + 'mcode-computer-use : type \'hello\''], + [{ tool_name: 'mcode-computer-use', tool_input: { action: 'screenshot' } }, + 'mcode-computer-use : screenshot'], + [{ tool_name: 'mcode-computer-use', tool_input: { action: 'scroll', coordinate: [100, 200] } }, + 'mcode-computer-use : scroll at (100,200)'], + // existing tools — make sure we didn't break them + [{ tool_name: 'Bash', tool_input: { command: 'ls -la /tmp' } }, + 'Bash : ls -la /tmp'], + [{ tool_name: 'Read', tool_input: { file_path: 'C:/foo/bar.txt' } }, + 'Read : C:/foo/bar.txt'], + [{ tool_name: 'Edit', tool_input: { file_path: 'C:/baz/qux.ts' } }, + 'Edit : C:/baz/qux.ts'], + ]; + + for (const [evt, expected] of cases) { + const script = ` +${fnBody} +$evt = '${JSON.stringify(evt).replace(/'/g, "''")}' | ConvertFrom-Json +$r = Format-ToolSummary $evt +Write-Output $r +`; + const r = await runPwsh(script); + const got = r.stdout.trim(); + if (got === expected) { + out('PASS', `Format-ToolSummary(${JSON.stringify(evt.tool_name)}) = "${expected}"`); + } else { + out('FAIL', `Format-ToolSummary(${JSON.stringify(evt.tool_name)}): expected "${expected}", got "${got}"`); + } + } +} + +// --------------------------------------------------------------------------- +// 4. Push-Island wrapper signature — hook can pass new params +// --------------------------------------------------------------------------- + +async function testPushIslandSignature() { + console.log('-'.repeat(60)); + console.log('4. Push-Island accepts new params'); + + const libSrc = await readFile(LIB, 'utf8'); + // Sanity: Push-Island declares Step/Total/Detail as named params. + const pushMatch = libSrc.match(/function Push-Island\s*\{[\s\S]*?\n\}/); + assert.ok(pushMatch, 'Push-Island must exist in _lib.ps1'); + const fnBody = pushMatch[0]; + if (!/\[int\]\$Step\s*=\s*-1/.test(fnBody)) { + out('FAIL', 'Push-Island: missing `[int]$Step = -1` param'); + } else { + out('PASS', 'Push-Island: declares [int]$Step = -1'); + } + if (!/\[int\]\$Total\s*=\s*-1/.test(fnBody)) { + out('FAIL', 'Push-Island: missing `[int]$Total = -1` param'); + } else { + out('PASS', 'Push-Island: declares [int]$Total = -1'); + } + if (!/\[string\]\$Detail\s*=\s*''/.test(fnBody)) { + out('FAIL', 'Push-Island: missing `[string]$Detail = \'\'` param'); + } else { + out('PASS', 'Push-Island: declares [string]$Detail = \'\''); + } + // And the call to notify-island.ps1 inside Push-Island must forward them + // The forward is via `$script:NotifyIsland` (a variable), not a literal + // "notify-island.ps1" string, so the regex anchors on the args. + if (!/-State\s+\$State\s+-Message\s+\$Message\s+-Step\s+\$Step\s+-Total\s+\$Total\s+-Detail\s+\$Detail/s.test(fnBody)) { + out('FAIL', 'Push-Island: does not forward -Step -Total -Detail to notify-island.ps1'); + } else { + out('PASS', 'Push-Island: forwards Step/Total/Detail to notify-island.ps1'); + } +} + +// --------------------------------------------------------------------------- +// main +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// 5. Push-Island → notify-island.ps1 → status.json integration +// --------------------------------------------------------------------------- + +async function testPushIslandIntegration() { + console.log('-'.repeat(60)); + console.log('5. Push-Island end-to-end (hook → status.json)'); + + const libSrc = await readFile(LIB, 'utf8'); + // Strip the Push-Island function declaration and dot-source it. + // _lib.ps1 also runs `Set-ConsoleUtf8` at load which is harmless + // for testing, but we need to skip the initial doc-comment / errors. + const r = await runPwsh(` +$ErrorActionPreference = 'Stop' +. '${LIB.replace(/\\/g, '\\\\')}' +Push-Island -State working -Message 'CU' -Step 3 -Total 12 -Detail 'fill username' +Get-Content (Join-Path $env:APPDATA 'mcode-island/status.json') -Raw +`); + if (r.code !== 0) { + out('FAIL', `Push-Island script exited ${r.code}: ${r.stderr}`); + return; + } + // Strip BOM if present + let raw = r.stdout; + if (raw.charCodeAt(0) === 0xFEFF) raw = raw.slice(1); + const j = JSON.parse(raw); + if (j.step === 3 && j.total === 12 && j.detail === 'fill username') { + out('PASS', `Push-Island end-to-end: status.json has step=3, total=12, detail="fill username"`); + } else { + out('FAIL', `Push-Island end-to-end: status.json has step=${j.step}, total=${j.total}, detail="${j.detail}"`); + } + + // Backward compat: Push-Island without new params must still work + const r2 = await runPwsh(` +$ErrorActionPreference = 'Stop' +. '${LIB.replace(/\\/g, '\\\\')}' +Push-Island -State working -Message 'Bash ok' +Get-Content (Join-Path $env:APPDATA 'mcode-island/status.json') -Raw +`); + let raw2 = r2.stdout; + if (raw2.charCodeAt(0) === 0xFEFF) raw2 = raw2.slice(1); + const j2 = JSON.parse(raw2); + if (j2.step === -1 && j2.total === -1 && j2.detail === '' && j2.message === 'Bash ok') { + out('PASS', `Push-Island backward compat: missing new params → step=-1, total=-1, detail=""`); + } else { + out('FAIL', `Push-Island backward compat: status.json has step=${j2.step}, total=${j2.total}, detail="${j2.detail}"`); + } +} + +async function main() { + console.log(`mcode-island sub-step progress self-check`); + console.log(`plugin root: ${PLUGIN_ROOT}`); + console.log(`pwsh: ${PWSH}`); + console.log('-'.repeat(60)); + + if (!(await exists(NOTIFY_ISLAND))) { + console.log(`[FAIL] notify-island.ps1 not found at ${NOTIFY_ISLAND}`); + process.exit(1); + } + if (!(await exists(WIDGET))) { + console.log(`[FAIL] mcode-island.ps1 not found at ${WIDGET}`); + process.exit(1); + } + if (!(await exists(LIB))) { + console.log(`[FAIL] _lib.ps1 not found at ${LIB}`); + process.exit(1); + } + + await testNotifySchema(); + await testBuildDisplayMessage(); + await testFormatToolSummaryCU(); + await testPushIslandSignature(); + await testPushIslandIntegration(); + + console.log('-'.repeat(60)); + console.log(`summary: ${pass} pass, ${fail} fail`); + process.exit(fail > 0 ? 1 : 0); +} + +main().catch((e) => { + console.error('FATAL:', e); + process.exit(2); +}); \ No newline at end of file diff --git a/plugins/antianqi/mcode-island/skills/mcode-island/SKILL.md b/plugins/antianqi/mcode-island/skills/mcode-island/SKILL.md index 6ab1c982..dc9bfde0 100644 --- a/plugins/antianqi/mcode-island/skills/mcode-island/SKILL.md +++ b/plugins/antianqi/mcode-island/skills/mcode-island/SKILL.md @@ -130,6 +130,33 @@ $plugin = "" # directory that contains notify-island.ps1 & "$plugin\notify-island.ps1" -State waiting -Message "permission prompt" ``` +For sub-step progress (Computer Use iterative loops, multi-step plans, +long-running tool sequences) push `-Step` / `-Total` / `-Detail` so the pill +shows what the agent is doing *right now* instead of only the coarse state: + +```powershell +& "$plugin\notify-island.ps1" -State working -Message "Computer Use" ` + -Step 3 -Total 12 -Detail "fill username field" +# → pill renders: "step 3/12 · fill username field" + +& "$plugin\notify-island.ps1" -State working -Message "Bash" ` + -Step 5 -Detail "npm install" +# → pill renders: "step 5 · npm install" (total omitted → no "/N") + +& "$plugin\notify-island.ps1" -State done -Message "Bash ok" +# → pill renders: "Bash ok" (no step → legacy behavior, fully backward compat) +``` + +Semantics: +- `-Step` is 1-based; omit (or pass `-1`) to keep the coarse state-only display. +- `-Total` is optional; pass `-1` or omit when the iteration count is unknown. +- `-Detail` is free text. When present, it replaces `Message` in the pill + ("step 3/12 · detail") to avoid stacking ("Bash ok · fill username"). When + absent, only the step number renders. + +All three params are optional and the schema is backward compatible — old +callers that omit them see no behavior change. + `` is the directory that contains `notify-island.ps1`. Substitute the absolute path your user installed the plugin at. The Skill body deliberately avoids hard-coded paths so any user / any install location works.