Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,11 @@ copilot plugin list # tokeneconomizer@tokeneconomizer ... (enabled)

Copilot loads the plugin from the clone directly, without copying it.

On Windows, its hooks and slash commands use PowerShell; Git Bash is not required.
From a PowerShell terminal in the clone, run `.\plugin\scripts\te.ps1 --host copilot status`.
The launcher honors `TE_PYTHON`, then looks for `python3`, `python`, or `py`, preferring
interpreters outside WindowsApps.

**2. Check it.** In Copilot's interactive mode, type `/tokeneconomizer:status`. Each slash command
is a model turn, and Copilot asks before running the plugin's script; choose **Yes**. Slash
commands work only in interactive mode; the plugin's notices also appear under `copilot -p`.
Expand All @@ -211,7 +216,11 @@ start says so. `/tokeneconomizer:toolprune restore` turns the server back on, an
`toolprune measure` shows the difference with no model call.

From a terminal, inside the repository: `~/.tokeneconomizer/bin/te --host copilot <command>` (the
launcher appears after the first session). A turn that is still stuck after the loop guard's
launcher appears after the first session). In PowerShell, use
`& "$HOME\.tokeneconomizer\bin\te.ps1" --host copilot <command>` instead (or the same path under
`$env:TOKENECONOMIZER_HOME` when set). Session-start and prompt hooks create or repair the launchers,
so an open session picks up the PowerShell launcher on its next prompt after updating.
A turn that is still stuck after the loop guard's
redirect shows "● TokenEconomizer: …" in the conversation (and on stdout under `copilot -p`); press
**Esc** twice to interrupt it, or Ctrl+C to end a `copilot -p` run.

Expand Down
6 changes: 6 additions & 0 deletions plugin/hooks/copilot-hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
{
"type": "command",
"command": "\"${PLUGIN_ROOT}/scripts/te-hook\" --host copilot",
"windows": "& (Join-Path $env:COPILOT_PLUGIN_ROOT 'scripts\\te-hook.ps1') --host copilot",
"timeout": 15
}
]
Expand All @@ -18,6 +19,7 @@
{
"type": "command",
"command": "\"${PLUGIN_ROOT}/scripts/te-hook\" --host copilot",
"windows": "& (Join-Path $env:COPILOT_PLUGIN_ROOT 'scripts\\te-hook.ps1') --host copilot",
"timeout": 20
}
]
Expand All @@ -29,6 +31,7 @@
{
"type": "command",
"command": "\"${PLUGIN_ROOT}/scripts/te-hook\" --host copilot",
"windows": "& (Join-Path $env:COPILOT_PLUGIN_ROOT 'scripts\\te-hook.ps1') --host copilot",
"timeout": 60
}
]
Expand All @@ -40,6 +43,7 @@
{
"type": "command",
"command": "\"${PLUGIN_ROOT}/scripts/te-hook\" --host copilot",
"windows": "& (Join-Path $env:COPILOT_PLUGIN_ROOT 'scripts\\te-hook.ps1') --host copilot",
"timeout": 60
}
]
Expand All @@ -51,6 +55,7 @@
{
"type": "command",
"command": "\"${PLUGIN_ROOT}/scripts/te-hook\" --host copilot",
"windows": "& (Join-Path $env:COPILOT_PLUGIN_ROOT 'scripts\\te-hook.ps1') --host copilot",
"timeout": 10
}
]
Expand All @@ -62,6 +67,7 @@
{
"type": "command",
"command": "\"${PLUGIN_ROOT}/scripts/te-hook\" --host copilot",
"windows": "& (Join-Path $env:COPILOT_PLUGIN_ROOT 'scripts\\te-hook.ps1') --host copilot",
"timeout": 10
}
]
Expand Down
6 changes: 6 additions & 0 deletions plugin/scripts/te-hook.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
if ($MyInvocation.ExpectingInput) {
$input | & "$PSScriptRoot\te.ps1" -Hook @args
} else {
& "$PSScriptRoot\te.ps1" -Hook @args
}
exit $LASTEXITCODE
51 changes: 51 additions & 0 deletions plugin/scripts/te.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
param([switch]$Hook)

$env:PYTHONUTF8 = '1'
$OutputEncoding = [Console]::InputEncoding = [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding
$mode = if ($Hook) { 'hook' } else { 'cli' }
$payload = if ($Hook) {
if ($MyInvocation.ExpectingInput) { $input -join "`n" } else { [Console]::In.ReadToEnd() }
}
$candidates = @(
foreach ($name in @($env:TE_PYTHON, 'python3', 'python', 'py')) {
if ($name) {
Get-Command $name -CommandType Application -All -ErrorAction SilentlyContinue
}
}
)
$python = $candidates | Where-Object { $_.Source -notlike '*\WindowsApps\*' } | Select-Object -First 1
if (-not $python) {
foreach ($candidate in $candidates) {
& $candidate.Source -c pass *> $null
if ($LASTEXITCODE -eq 0) { $python = $candidate; break }
}
}
if (-not $python) {
$message = 'te: no Python on PATH (set TE_PYTHON)'
if ($Hook) {
$root = if ($env:TOKENECONOMIZER_HOME) { $env:TOKENECONOMIZER_HOME } else {
Join-Path $HOME '.tokeneconomizer'
}
try {
[IO.Directory]::CreateDirectory($root) | Out-Null
[IO.File]::AppendAllText((Join-Path $root 'errors.log'), "$message`n")
} catch [IO.IOException] {
[Console]::Error.WriteLine($_.Exception.Message)
} catch [UnauthorizedAccessException] {
[Console]::Error.WriteLine($_.Exception.Message)
}
'{}'
exit 0
}
[Console]::Error.WriteLine($message)
exit 1
}
$pythonArgs = @()
if ($python.Name -eq 'py.exe' -or $python.Name -eq 'py') { $pythonArgs += '-3' }
$pythonArgs += @((Join-Path $PSScriptRoot 'te_bootstrap.py'), $mode)
if ($Hook) {
$payload | & $python.Source @pythonArgs @args
} else {
& $python.Source @pythonArgs @args
}
exit $LASTEXITCODE
11 changes: 10 additions & 1 deletion plugin/slash-commands/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,16 @@ description: Show or change TokenEconomizer's settings - turn each mechanism on
argument-hint: "[show | set KEY=VALUE ... [--global] | unset KEY ... [--global]]"
---
The arguments I typed after the command are: "$ARGUMENTS" (it shows every setting when there are none).
Run this exact command with the Bash tool, then show me its output verbatim in a code block:
Run the command for this shell, then show me its output verbatim in a code block.

In Copilot CLI on Windows, use the PowerShell tool:

```powershell
$root = if ($env:TOKENECONOMIZER_HOME) { $env:TOKENECONOMIZER_HOME } else { Join-Path $HOME '.tokeneconomizer' }
& (Join-Path $root 'bin\te.ps1') --host copilot --cwd (Get-Location).Path config $ARGUMENTS
```

Otherwise, use the Bash tool:

```
te="${CLAUDE_PLUGIN_ROOT}/scripts/te"; [ -x "$te" ] || te="${TOKENECONOMIZER_HOME:-$HOME/.tokeneconomizer}/bin/te"; "$te" --cwd "$(pwd)" config $ARGUMENTS
Expand Down
11 changes: 10 additions & 1 deletion plugin/slash-commands/facts.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
---
description: Earlier name of /tokeneconomizer:microskills - show the repository facts TokenEconomizer injects
---
Run this exact command with the Bash tool, then show me its output verbatim in a code block, with no commentary:
Run the command for this shell, then show me its output verbatim in a code block, with no commentary.

In Copilot CLI on Windows, use the PowerShell tool:

```powershell
$root = if ($env:TOKENECONOMIZER_HOME) { $env:TOKENECONOMIZER_HOME } else { Join-Path $HOME '.tokeneconomizer' }
& (Join-Path $root 'bin\te.ps1') --host copilot --cwd (Get-Location).Path microskills
```

Otherwise, use the Bash tool:

```
te="${CLAUDE_PLUGIN_ROOT}/scripts/te"; [ -x "$te" ] || te="${TOKENECONOMIZER_HOME:-$HOME/.tokeneconomizer}/bin/te"; "$te" --cwd "$(pwd)" microskills
Expand Down
11 changes: 10 additions & 1 deletion plugin/slash-commands/microskills.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
---
description: Show the repository facts (micro-skills) TokenEconomizer learned and injects at session start
---
Run this exact command with the Bash tool, then show me its output verbatim in a code block, with no commentary:
Run the command for this shell, then show me its output verbatim in a code block, with no commentary.

In Copilot CLI on Windows, use the PowerShell tool:

```powershell
$root = if ($env:TOKENECONOMIZER_HOME) { $env:TOKENECONOMIZER_HOME } else { Join-Path $HOME '.tokeneconomizer' }
& (Join-Path $root 'bin\te.ps1') --host copilot --cwd (Get-Location).Path microskills
```

Otherwise, use the Bash tool:

```
te="${CLAUDE_PLUGIN_ROOT}/scripts/te"; [ -x "$te" ] || te="${TOKENECONOMIZER_HOME:-$HOME/.tokeneconomizer}/bin/te"; "$te" --cwd "$(pwd)" microskills
Expand Down
11 changes: 10 additions & 1 deletion plugin/slash-commands/repos.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,16 @@ description: List every repository TokenEconomizer keeps state for, or merge one
argument-hint: "[list | migrate | merge KEY [INTO]]"
---
The arguments I typed after the command are: "$ARGUMENTS" (it lists the repositories when there are none).
Run this exact command with the Bash tool, then show me its output verbatim in a code block:
Run the command for this shell, then show me its output verbatim in a code block.

In Copilot CLI on Windows, use the PowerShell tool:

```powershell
$root = if ($env:TOKENECONOMIZER_HOME) { $env:TOKENECONOMIZER_HOME } else { Join-Path $HOME '.tokeneconomizer' }
& (Join-Path $root 'bin\te.ps1') --host copilot --cwd (Get-Location).Path repos $ARGUMENTS
```

Otherwise, use the Bash tool:

```
te="${CLAUDE_PLUGIN_ROOT}/scripts/te"; [ -x "$te" ] || te="${TOKENECONOMIZER_HOME:-$HOME/.tokeneconomizer}/bin/te"; "$te" --cwd "$(pwd)" repos $ARGUMENTS
Expand Down
11 changes: 10 additions & 1 deletion plugin/slash-commands/status.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
---
description: Show what TokenEconomizer has learned for this repository and what it has done
---
Run this exact command with the Bash tool, then show me its output verbatim in a code block. Add nothing unless the output contains a warning, in which case explain the warning in one sentence.
Run the command for this shell, then show me its output verbatim in a code block. Add nothing unless the output contains a warning, in which case explain the warning in one sentence.

In Copilot CLI on Windows, use the PowerShell tool:

```powershell
$root = if ($env:TOKENECONOMIZER_HOME) { $env:TOKENECONOMIZER_HOME } else { Join-Path $HOME '.tokeneconomizer' }
& (Join-Path $root 'bin\te.ps1') --host copilot --cwd (Get-Location).Path status
```

Otherwise, use the Bash tool:

```
te="${CLAUDE_PLUGIN_ROOT}/scripts/te"; [ -x "$te" ] || te="${TOKENECONOMIZER_HOME:-$HOME/.tokeneconomizer}/bin/te"; "$te" --cwd "$(pwd)" status
Expand Down
11 changes: 10 additions & 1 deletion plugin/slash-commands/toolprune.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,16 @@ description: Review the tools TokenEconomizer left out of this repository's sess
argument-hint: "[show | apply [TOOL ...] | restore [TOOL ...] | refresh-catalog | measure]"
---
The arguments I typed after the command are: "$ARGUMENTS" (it shows the proposal when there are none).
Run this exact command with the Bash tool, then show me its output verbatim in a code block:
Run the command for this shell, then show me its output verbatim in a code block.

In Copilot CLI on Windows, use the PowerShell tool:

```powershell
$root = if ($env:TOKENECONOMIZER_HOME) { $env:TOKENECONOMIZER_HOME } else { Join-Path $HOME '.tokeneconomizer' }
& (Join-Path $root 'bin\te.ps1') --host copilot --cwd (Get-Location).Path toolprune $ARGUMENTS
```

Otherwise, use the Bash tool:

```
te="${CLAUDE_PLUGIN_ROOT}/scripts/te"; [ -x "$te" ] || te="${TOKENECONOMIZER_HOME:-$HOME/.tokeneconomizer}/bin/te"; "$te" --cwd "$(pwd)" toolprune $ARGUMENTS
Expand Down
11 changes: 10 additions & 1 deletion plugin/slash-commands/untool.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,16 @@ description: Earlier name of /tokeneconomizer:toolprune - review the deny list,
argument-hint: "[show | apply [TOOL ...] | restore [TOOL ...] | refresh-catalog | measure]"
---
The arguments I typed after the command are: "$ARGUMENTS" (it shows the proposal when there are none).
Run this exact command with the Bash tool, then show me its output verbatim in a code block:
Run the command for this shell, then show me its output verbatim in a code block.

In Copilot CLI on Windows, use the PowerShell tool:

```powershell
$root = if ($env:TOKENECONOMIZER_HOME) { $env:TOKENECONOMIZER_HOME } else { Join-Path $HOME '.tokeneconomizer' }
& (Join-Path $root 'bin\te.ps1') --host copilot --cwd (Get-Location).Path toolprune $ARGUMENTS
```

Otherwise, use the Bash tool:

```
te="${CLAUDE_PLUGIN_ROOT}/scripts/te"; [ -x "$te" ] || te="${TOKENECONOMIZER_HOME:-$HOME/.tokeneconomizer}/bin/te"; "$te" --cwd "$(pwd)" toolprune $ARGUMENTS
Expand Down
90 changes: 90 additions & 0 deletions tests/test_plugin_package.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ def test_hook_files_run_the_launcher_on_every_event(name, host):
assert f"--host {host}" in h["command"]
if host == "copilot":
assert h["command"].startswith('"${PLUGIN_ROOT}/scripts/te-hook"')
assert "te-hook.ps1" in h["windows"] and "--host copilot" in h["windows"]
if host == "codex":
assert h["commandWindows"] == CODEX_WINDOWS
if event == "SessionEnd":
Expand Down Expand Up @@ -143,6 +144,95 @@ def _hook_env(tmp_path, **extra):
return env


POWERSHELLS = [p for p in (shutil.which("powershell"), shutil.which("pwsh")) if p]


@pytest.mark.parametrize("shell", POWERSHELLS or [None])
@pytest.mark.parametrize("event", ["SessionStart", "UserPromptSubmit"])
@pytest.mark.skipif(os.name != "nt" or not POWERSHELLS, reason="native Windows PowerShell")
def test_powershell_hook_and_slash_commands_without_git_bash(tmp_path, shell, event):
work = tmp_path / "work space \u00e9"
work.mkdir()
scripts = tmp_path / "plugin's copy" / "scripts"
shutil.copytree(os.path.join(PLUGIN, "scripts"), scripts,
ignore=shutil.ignore_patterns("__pycache__"))
env = _hook_env(tmp_path, TE_PYTHON=sys.executable, TE_CORE_PATH=REPO,
COPILOT_PLUGIN_ROOT=str(scripts.parent))
env["PATH"] = os.path.dirname(sys.executable)
command = next(_commands(_json(PLUGIN, "hooks", "copilot-hooks.json")))[2]["windows"]

def run(script, payload=None):
return subprocess.run([shell, "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command",
script], input=payload, capture_output=True, encoding="utf-8",
env=env, cwd=work, timeout=60)

payload = json.dumps({"hook_event_name": event, "session_id": "s",
"cwd": str(work), "source": "new"}, ensure_ascii=False)
out = run(command, payload)
assert out.returncode == 0 and json.loads(out.stdout) == {}, out.stderr
errors = tmp_path / "home" / "errors.log"
assert not errors.exists(), errors.read_text(encoding="utf-8")
sessions = list((tmp_path / "home" / "repos").glob("*/copilot/sessions/s.json"))
assert sessions, (out.stdout, out.stderr)
assert json.loads(sessions[0].read_text(encoding="utf-8"))["cwd"] == str(work)
for name in ("status", "config", "repos", "microskills", "facts", "toolprune", "untool"):
text = open(os.path.join(PLUGIN, "slash-commands", name + ".md"), encoding="utf-8").read()
script = re.search(r"```powershell\n(.*?)\n```", text, re.S).group(1)
out = run(script.replace("$ARGUMENTS", ""))
assert out.returncode == 0 and out.stdout.strip(), (name, out.stderr)
assert (tmp_path / "home" / "bin" / "te.ps1").exists()
config = open(os.path.join(PLUGIN, "slash-commands", "config.md"), encoding="utf-8").read()
script = re.search(r"```powershell\n(.*?)\n```", config, re.S).group(1)
out = run(script.replace("$ARGUMENTS", "set loopguard=off"))
assert out.returncode == 0, out.stderr
settings = list((tmp_path / "home" / "repos").glob("*/config.json"))
assert settings and json.loads(settings[0].read_text())["loopguard"] == "off"
out = run(f"'{payload.replace(chr(39), chr(39) * 2)}' | {command}")
assert out.returncode == 0 and json.loads(out.stdout) == {}, out.stderr
env.update(PATH="", TE_PYTHON=str(tmp_path / "missing.exe"))
out = run(command, payload)
assert out.returncode == 0 and json.loads(out.stdout) == {}, out.stderr
assert "no Python" in (tmp_path / "home" / "errors.log").read_text()
out = run("& (Join-Path $env:COPILOT_PLUGIN_ROOT 'scripts\\te.ps1') status")
assert out.returncode == 1 and "no Python" in out.stderr


@pytest.mark.parametrize("shell", POWERSHELLS or [None])
@pytest.mark.skipif(os.name != "nt" or not POWERSHELLS, reason="native Windows PowerShell")
def test_powershell_launcher_forwards_arguments_and_exit_code(tmp_path, shell):
scripts = tmp_path / "plugin's scripts"
scripts.mkdir()
shutil.copy(os.path.join(PLUGIN, "scripts", "te.ps1"), scripts)
(scripts / "te_bootstrap.py").write_text(
"import json, sys\nprint(json.dumps(sys.argv[1:]))\nsys.exit(7)\n")
command = "& '%s' --host copilot --cwd 'a path' config set 'paid_model=a&b'; exit $LASTEXITCODE" % (
str(scripts / "te.ps1").replace("'", "''"))
out = subprocess.run([shell, "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", command],
capture_output=True, encoding="utf-8",
env=_hook_env(tmp_path, TE_PYTHON=sys.executable), timeout=60)
assert out.returncode == 7, out.stderr
assert json.loads(out.stdout) == ["cli", "--host", "copilot", "--cwd", "a path",
"config", "set", "paid_model=a&b"]
winapps = tmp_path / "Microsoft" / "WindowsApps"
winapps.mkdir(parents=True)
marker = tmp_path / "alias_called"
(winapps / "python3.cmd").write_text(f'@echo alias>"{marker}"\n@exit /b 1\n')
env = _hook_env(tmp_path, TE_PYTHON="",
PATH=os.pathsep.join((str(winapps), os.path.dirname(sys.executable))))
out = subprocess.run([shell, "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", command],
capture_output=True, encoding="utf-8", env=env, timeout=60)
assert out.returncode == 7, out.stderr
assert json.loads(out.stdout)[0] == "cli"
assert not marker.exists()
(winapps / "python3.cmd").write_text(f'@"{sys.executable}" %*\n')
env["PATH"] = str(winapps)
command = "& '%s' status; exit $LASTEXITCODE" % str(scripts / "te.ps1").replace("'", "''")
out = subprocess.run([shell, "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", command],
capture_output=True, encoding="utf-8", env=env, timeout=60)
assert out.returncode == 7, out.stderr
assert json.loads(out.stdout) == ["cli", "status"]


def test_the_launcher_runs_the_dispatcher_from_the_checkout(tmp_path):
work = tmp_path / "work"
work.mkdir()
Expand Down
9 changes: 5 additions & 4 deletions tokeneconomizer/plugin/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,16 +95,17 @@ def handle(self):
self.ctx.settle()
self.cfg = self.ctx.config
event = self.p.get("hook_event_name") or ""
if event in ("SessionStart", "UserPromptSubmit"):
try:
ensure_launcher(self.ctx.env)
except OSError:
self._record_error()
handler = self.handler(event)
return hookqueue.run(self, event, handler) if handler else {}

# ---- SessionStart -----------------------------------------------------------------
def session_start(self):
source = self.p.get("source") or "startup"
try:
ensure_launcher(self.ctx.env)
except OSError:
pass
s = self._session()
s["source"] = source
s.setdefault("cwd", self.host.cwd(self.p, self.ctx.env))
Expand Down
Loading
Loading