From a1a99d17aa5926b59ffc50238f63a7ceb0939add Mon Sep 17 00:00:00 2001 From: thezelium <147515134+thezelium@users.noreply.github.com> Date: Fri, 6 Nov 2026 14:03:38 +0100 Subject: [PATCH 01/11] droid: support Factory Droid (micro-skills, loop guard, session cost) Factory Droid documents the same hook contract this plugin already speaks -- session_id, transcript_path, cwd, hook_event_name, and SessionStart's hookSpecificOutput.additionalContext -- so it needs an adapter, not a second design. - trace/droid.py reads a Droid session: one JSONL file per session under ~/.factory/sessions//, a session_start header carrying the cwd, and message records shaped like a Claude Code transcript. Tool names are mapped (Execute and Script to Bash, Create and Write to Write, Edit and ApplyPatch to Edit). `started` is epoch seconds, as on every other host. - A prompt is a user message that is neither an injected reminder nor a tool result, and not one Droid hides (`visibility: user_only` or `llm_only`). - Session cost: Droid's transcript carries no usage, but `.settings.json` beside it carries `tokenUsage`. Only that figure is read -- `inclusiveTokenUsage` also counts child sessions, which are folded on their own. - plugin/hosts.py grows a Host base with defaults for the reader methods the mechanisms call, and a Droid host on top; history.py uses those methods instead of Claude Code's functions, so the history import, the envelope replay and the folder rule work for either. - Side calls run through `droid exec`, which writes a session log of its own; they stay off unless TE_DROID_SIDECALL=1. With them off, `microskills=envfacts` still learns. - Tool pruning reports itself unavailable: the TUI has no persistent per-tool setting (Factory issue #65), and `droid exec --only-tools` has no interactive equivalent. - scripts/build_plugin.py --droid writes a native bundle: .factory-plugin/plugin.json, hooks/hooks.json in Droid's top-level shape, commands/ named tokeneconomizer-*, and the package copied into lib/ (Droid copies the plugin into its cache, so a symlink would break). - Tests: tests/test_droid.py covers the reader, the mapping, usage and fallback pricing, the history import, the hidden-message rule, the hook's own output including an injected fact, and the bundle; test_live_hooks.py exercises the guard on Droid payloads. Not verified end to end: a learned fact reaching a live Droid model. The hook writes the right JSON; Droid appending it to the model's context is documented, not observed here. Refs: microsoft/TokenEconomizer#85 --- CLAUDE.md | 9 +- README.md | 38 +++- docs/DROID.md | 130 +++++++++++ docs/PLUGIN.md | 26 +++ plugin/.factory-plugin/plugin.json | 12 + plugin/README.md | 48 +++- plugin/hooks/droid-hooks.json | 57 +++++ scripts/build_plugin.py | 67 +++++- tests/test_droid.py | 276 +++++++++++++++++++++++ tests/test_live_core.py | 3 +- tests/test_live_hooks.py | 17 +- tokeneconomizer/plugin/cli.py | 25 ++- tokeneconomizer/plugin/history.py | 40 ++-- tokeneconomizer/plugin/hosts.py | 139 +++++++++++- tokeneconomizer/plugin/sidecall.py | 113 +++++++++- tokeneconomizer/trace/__init__.py | 11 +- tokeneconomizer/trace/droid.py | 340 +++++++++++++++++++++++++++++ 17 files changed, 1301 insertions(+), 50 deletions(-) create mode 100644 docs/DROID.md create mode 100644 plugin/.factory-plugin/plugin.json create mode 100644 plugin/hooks/droid-hooks.json create mode 100644 tests/test_droid.py create mode 100644 tokeneconomizer/trace/droid.py diff --git a/CLAUDE.md b/CLAUDE.md index fa43927..6f39b19 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,8 +1,8 @@ # TokenEconomizer A plugin for Claude Code, Codex, the Copilot CLI and Pi (experimentally opencode, Cursor, Grok Build, -Hermes Agent and the Antigravity CLI) that reads finished sessions and changes what later sessions -in the same repository pay for: micro-skills, tool pruning, and a loop guard. +Hermes Agent, the Antigravity CLI and Factory Droid) that reads finished sessions and changes what +later sessions in the same repository pay for: micro-skills, tool pruning, and a loop guard. See `README.md` for the overview and the method, `docs/PLUGIN.md` for the design and the verified agent behavior, and `docs/ARCHITECTURE.md` for how the package fits together. @@ -12,7 +12,7 @@ agent behavior, and `docs/ARCHITECTURE.md` for how the package fits together. - `toolprune/` — unused-tool evidence and the deny-list proposal; Codex capability packages - `loopguard/` — the per-call failure-loop guard and its per-repository envelope - `trace/` — each agent's session-log format (Claude Code, Codex, Copilot CLI, Pi, opencode, - Cursor, Grok Build, Hermes Agent, Antigravity CLI) + Cursor, Grok Build, Hermes Agent, Antigravity CLI, Factory Droid) - `plugin/` — the runtime: hook dispatch, per-repository state, settings, the `te` CLI - `config.py`, `ledger.py`, `pricing.py`, `domain.py`, `stage.py` — shared by all three - `plugin/` — the installable plugin directory: manifests per agent, hook files, launchers @@ -20,7 +20,8 @@ agent behavior, and `docs/ARCHITECTURE.md` for how the package fits together. and Copilot manifests, since Codex would turn a `commands/` folder into duplicate skills), Codex skills (`codex-skills/`, Codex having no plugin slash commands), the Pi extension, the opencode plugin (`opencode/`, one file whose default export serves 1.x via `server` and 2.x via - `setup`) and the Hermes plugin; `lib/` links the package + `setup`), the Hermes plugin, and the Droid manifest (`plugin/.factory-plugin/`, with + `plugin/hooks/droid-hooks.json`); `lib/` links the package - `experiments/` — benchmark-only: `te_bench/` (adapters, arms, facade, benchmark-only controllers), `te_harness/` (Docker runners), `scripts/`, `workloads/`, its own `tests/`; its `README.md` is the run book diff --git a/README.md b/README.md index 449a089..7d65b21 100644 --- a/README.md +++ b/README.md @@ -69,8 +69,8 @@ mechanisms: itself. TokenEconomizer is a plugin for **Claude Code**, **Codex**, the **GitHub Copilot CLI** and **Pi**, -with experimental support for **opencode**, **Cursor Agent**, **Grok Build**, **Hermes Agent** and -the **Antigravity CLI**. Each italic +with experimental support for **opencode**, **Cursor Agent**, **Grok Build**, **Hermes Agent**, +the **Antigravity CLI** and **Factory Droid**. Each italic letter above is a setting, shown with its default ([Settings](#settings)), and each mechanism can be turned on or off. The plugin announces each action in one line, and if any part of it fails, your session carries on as if it were not installed. Its model calls (the fact proposer, and the judge with the @@ -167,6 +167,23 @@ pi install git:github.com/microsoft/TokenEconomizer Start `pi` and run `/te status`. +**Factory Droid** (experimental; micro-skills, the loop guard and session cost, not tool pruning) + +```sh +git clone https://github.com/microsoft/TokenEconomizer.git +cd TokenEconomizer +python scripts/build_plugin.py --droid +droid plugin marketplace add "$PWD/dist/tokeneconomizer-droid" +droid plugin install tokeneconomizer@tokeneconomizer-droid --scope user +``` + +Start a new Droid session (its hooks are snapshotted at startup) and run +`/tokeneconomizer-status`. Droid keeps each session's token usage in a settings file beside its +log, so folded sessions are priced like any other host's. Its TUI has no persistent per-tool +setting, so tool pruning stays off; and because `droid exec` writes a session log of its own, +micro-skills' paid proposer and the loop guard's judge stay off unless `TE_DROID_SIDECALL=1`. +See **[`docs/DROID.md`](docs/DROID.md)** for what works and how to import earlier sessions. + **Cursor Agent** (experimental) ```sh @@ -249,6 +266,7 @@ To update: | Agent | Update | |---|---| | Claude Code, Copilot CLI, opencode | `git pull` in the clone | +| Factory Droid | `git pull` in the clone, then the `build_plugin.py --droid` and install lines again | | Hermes Agent | `git pull` in the clone, then `hermes gateway restart` | | Antigravity CLI | build the bundle again and `agy plugin install` it | | Codex | `git pull` in the clone, then the `codex plugin add` line again | @@ -270,6 +288,7 @@ uninstalling, using Copilot with your own model key, and troubleshooting. | opencode (experimental) | 1.19.5 and 2.2.0 | not tested | | Hermes Agent (experimental) | 0.22.5 | not tested | | Antigravity CLI (experimental) | agy 1.5.1, macOS and Linux | not tested | +| Factory Droid (experimental) | 0.233.0, macOS (below) | not tested | The commands above were tested on the oldest and newest version in each range; newer versions have not been tested yet. @@ -298,6 +317,12 @@ have not been tested yet. not. Its other surfaces (the Ink TUI, the Desktop app, messaging platforms, cron) and subagents are ignored, and tool pruning is not available. See [its limits](plugin/README.md#hermes-agent). +- **Factory Droid** support is experimental: it was tested on 0.233.0 only, on macOS. Droid + documents the hook contract this adapter uses, and the plugin's own hook output is covered by + tests, but a learned fact reaching a live Droid model has not yet been verified. Droid's TUI + has no persistent per-tool setting (Factory issue #65), so tool pruning is not available; its + session logs are read for micro-skills and the loop guard, and priced from the usage Droid + writes beside each log. See [its notes](docs/DROID.md). ### Everyday commands @@ -314,13 +339,14 @@ commands. They have the same names on every agent; only the way you type them di | Grok Build | from a terminal: `~/.tokeneconomizer/bin/te --host grok ` | | Hermes Agent | from a terminal: `~/.tokeneconomizer/bin/te --host hermes ` | | Antigravity CLI | from a terminal: `~/.tokeneconomizer/bin/te --host antigravity ` | +| Factory Droid | `/tokeneconomizer-` (e.g. `/tokeneconomizer-status`) | | a terminal, in the repository | `~/.tokeneconomizer/bin/te ` | | Command | What it does | |---|---| | `status` | what it learned and did here, and what your sessions cost | | `microskills` | the facts it injects, and those still forming | -| `toolprune` | the tools it left out, and the evidence (not on Pi, opencode, Hermes Agent or the Antigravity CLI) | +| `toolprune` | the tools it left out, and the evidence (not on Pi, opencode, Hermes Agent, the Antigravity CLI or Factory Droid) | | `toolprune restore` | give them back; they will not be removed again | | `config` | every setting in effect | | `config set microskills=on` | turn a mechanism on (or `=off`) in this repository; add `--global` for every repository | @@ -404,6 +430,7 @@ what it can leave out differs: | opencode | nothing yet | — | | Hermes Agent | nothing yet | — | | Antigravity CLI | nothing yet | — | +| Factory Droid | nothing (no persistent per-tool setting in the TUI) | — | Tool pruning adds only its own entries and never touches deny rules you wrote; a settings file it creates is kept out of git (`.git/info/exclude`). On Claude Code, the session-start line that says @@ -439,6 +466,7 @@ Where that line appears, and how to stop the turn, in the terminal UI and in a h | Pi | as a notification | Esc | on stderr | Ctrl+C | | opencode | as a notification | Esc | on stderr | Ctrl+C | | Hermes Agent | in the model's reply | Ctrl+C | in the final reply | Ctrl+C | +| Factory Droid | in the model's reply | Interrupt the agent | in the final reply | Ctrl+C | - **Claude Code** shows a plugin's own lines only in its detailed transcript (Ctrl+O), so the plugin asks Claude to repeat this one in its reply; the model may word it differently or leave @@ -462,9 +490,9 @@ agent's own limits; afterwards, `te status` counts the redirects and "still stuc | [`tokeneconomizer/loopguard`](tokeneconomizer/loopguard/) | Failure-loop intervention: the per-call guard and its per-repository envelope | | [`tokeneconomizer/trace`](tokeneconomizer/trace/) | Each agent's session log, read into one shape | | [`tokeneconomizer/plugin`](tokeneconomizer/plugin/) | The runtime the hooks and the `te` command run | -| [`plugin`](plugin/) | The installable plugin: manifests, hook files, launchers, slash commands, the Pi extension, the opencode and Hermes plugins | +| [`plugin`](plugin/) | The installable plugin: manifests, hook files, launchers, slash commands, the Pi extension, the opencode and Hermes plugins, the Droid bundle (`.factory-plugin/`) | | [`experiments`](experiments/) | The harness, launchers and task sets behind the paper's results | -| [`docs`](docs/) | `PLUGIN.md` (design, verified agent behavior) and `ARCHITECTURE.md` (how the package fits together) | +| [`docs`](docs/) | `PLUGIN.md` (design, verified agent behavior), `ARCHITECTURE.md` (how the package fits together) and `DROID.md` (the Factory Droid adapter) | | [`tests`](tests/) | Offline tests for the package and the plugin | The package is standard-library only. Run the tests with `python -m pytest tests` (add diff --git a/docs/DROID.md b/docs/DROID.md new file mode 100644 index 0000000..ba1af44 --- /dev/null +++ b/docs/DROID.md @@ -0,0 +1,130 @@ +# TokenEconomizer on Factory Droid + +Droid support is experimental. It was built and tested against Droid `0.233.0` on macOS; no +other version has been tested. It is not part of an upstream release yet. + +## What works, and what does not + +| Mechanism | Droid | Why | +|---|---|---| +| Micro-skills (repository facts at session start) | yes | `SessionStart` supports `hookSpecificOutput.additionalContext` | +| History import (learn from earlier sessions) | yes | `~/.factory/sessions//.jsonl` has the tool calls | +| Session cost / effective tokens | yes | `.settings.json` holds `tokenUsage` beside the log | +| Loop guard (redirect a failing loop) | partially | `PostToolUse` supports `additionalContext`; the judge needs `droid exec`, which is off by default | +| Tool pruning | no | Droid's TUI has no persistent per-tool setting; `droid exec --only-tools` has no interactive equivalent (Factory issue #65) | + +The loop guard still learns its envelope from live turns that end normally, still redirects on +its rule signals, and still sends the "still stuck" notice through the model's reply, because +Droid documents no `systemMessage`. The model judge is what needs a side call, and is therefore +off by default. + +## Why a session may learn nothing + +Micro-skills reports what it has learned per repository. A directory that is not a git +repository is keyed by its own path, so starting every session in a folder that holds many +unrelated projects mixes evidence from all of them, and a fact from one is contradicted by +another. Start Droid in the project's own folder when you want it to learn that project. + +The free layer (`microskills=envfacts`) learns only from repeated environment failures: a command +that fails the same way several sessions in a row and a working alternative. It needs 3 +confirmations and 4 sessions in which the fact was exercised before it injects anything. A +repository whose sessions never run tests, builds or failing commands will produce no fact, by +design. The paid layer (`microskills=envfacts+distiller`, or `on`) asks the model for repository +knowledge every few sessions, and needs a side call. + +## Install + +```sh +git clone https://github.com/microsoft/TokenEconomizer.git +cd TokenEconomizer +python scripts/build_plugin.py --droid +droid plugin marketplace add "$PWD/dist/tokeneconomizer-droid" +droid plugin install tokeneconomizer@tokeneconomizer-droid --scope user +``` + +The bundle is a native Droid plugin: `.factory-plugin/plugin.json` at its root, hooks in +`hooks/hooks.json` in Droid's top-level shape, commands in `commands/`, and the Python package +in `lib/` (Droid copies the plugin into its cache, so a symlink would break). Start a new Droid +session after installing: Droid snapshots hooks at startup. + +Check it in the repository: + +```sh +~/.tokeneconomizer/bin/te --host droid --cwd "$PWD" status +``` + +## Import the sessions you already ran + +A plugin installed today knows nothing about yesterday's sessions. Droid's own log directory is +read by the same history import the other hosts use: + +```sh +~/.tokeneconomizer/bin/te --host droid --cwd "$PWD" history import +``` + +The import runs only into a repository with nothing folded yet, folds oldest first, skips +subagent sessions (`callingSessionId`), sessions with no prompt and sessions still being written, +and makes no model call. `history_import=on` lets the first session start in a repository do it +by itself. + +Their usage is recorded, but `te status` counts imported sessions apart from the plugin's own +(`+N from earlier history`), because they ran before it: the totals in `sessions ... effective +tokens` cover the sessions that end after the plugin is installed. + +## Settings + +The plugin's settings live in `~/.tokeneconomizer/config.json`: + +```sh +te --host droid config set microskills=envfacts # learn facts, no model call +te --host droid config set loopguard=nudge # the judge stays off until side calls are on +te --host droid config set toolprune=off # the only honest value on Droid +``` + +`TE_DISABLE=1` in a session's environment turns every hook into a no-op. `te --host droid doctor` +reports the Droid version, the session directory, side-call state and recent errors. + +## Side calls are off by default + +Micro-skills' paid proposer and the loop guard's judge normally call the agent's own CLI. On +Droid that is `droid exec`, which writes a session log of its own under `~/.factory/sessions`. +Droid has no documented flags for a pure completion (no tools, no hooks, no log), so the adapter +does not make that call unless `TE_DROID_SIDECALL=1` is set. With it off, `microskills=envfacts` +still learns, and the loop guard keeps its rule-based backstop. + +When it is on, the call runs at Droid's default read-only autonomy in a throwaway directory, and +its cost is read from the usage Droid records for it. + +## What is verified, and what is not + +- Verified locally: the session reader, prompt counting, the tool-name mapping, folding a + session, the history import, usage and pricing, the hook's own JSON output, and the loop + guard's redirect and notice paths. Each has a test in `tests/test_droid.py`. +- Documented by Factory and relied on here: `SessionStart` appends + `hookSpecificOutput.additionalContext`; `UserPromptSubmit` and `PostToolUse` accept + `additionalContext`; there is no `systemMessage` and no `PostToolUseFailure`. +- **Not verified**: a learned fact reaching a live Droid model. The hook writes the right JSON, + but the last hop (Droid appending it to the model's context) has not been observed end to end. + Treat this as the open item for Droid support. + +Droid's session and hook formats are internal. The adapter fails open: any error answers `{}` and +writes one line to `~/.tokeneconomizer/errors.log`, so a Droid update can at worst stop the plugin +from learning, never break a session. + +## Uninstall and rollback + +```sh +droid plugin uninstall tokeneconomizer@tokeneconomizer-droid --scope user +droid plugin marketplace remove tokeneconomizer-droid +rm -rf ~/.tokeneconomizer +``` + +The plugin writes nothing to `~/.factory/settings.json`, `~/.factory/hooks.json` or any +repository file: its hooks come from the plugin directory, its state from `~/.tokeneconomizer`. +Deleting that directory forgets everything it learned. + +## Compatibility + +Tested on Droid `0.233.0` (macOS). After a Droid update, run one session and check +`te --host droid doctor` and `~/.tokeneconomizer/errors.log`. If Droid changes the session log +shape, `te --host droid history import` will import nothing, which is the safe failure. diff --git a/docs/PLUGIN.md b/docs/PLUGIN.md index 2e09257..434d2e4 100644 --- a/docs/PLUGIN.md +++ b/docs/PLUGIN.md @@ -686,6 +686,32 @@ was not.) * **Side call, verified on 1.0.70.** With the side call's flags, the run's recorded tool list held only `read_file`, and a prompt asking it to create a file left none. +**Factory Droid 0.233.0 -- adapter built 2026-11-05, partially verified:** + +* The hook contract is Claude-shaped (`session_id`, `transcript_path`, `cwd`, + `hook_event_name`) with Droid's own tool names (`Execute`, `Script`, `Create`, `Edit`, + `ApplyPatch`, ...). Factory's documentation names the same events this plugin registers -- + `SessionStart`, `UserPromptSubmit`, `PostToolUse`, `Stop`, `SessionEnd` -- and says a + `SessionStart` hook's `hookSpecificOutput.additionalContext` is appended to the new session's + context. Droid documents no `systemMessage` and no `PostToolUseFailure`. +* **Offline, tested here:** the session reader and the tool-name mapping; prompt counting + (Droid's own `user_only` and `llm_only` messages and injected reminders are not prompts); + folding a session and the history import; the `SessionStart` hook's own JSON, including an + injected fact; usage and pricing; and the loop guard's redirect and notice paths on Droid + payloads. `tests/test_droid.py` and `tests/test_live_hooks.py` cover each. +* **Session cost.** Droid's transcript carries no usage, but `.settings.json` + beside it carries `tokenUsage` (`inputTokens`, `outputTokens`, `cacheCreationTokens`, + `cacheReadTokens`, `thinkingTokens`, `factoryCredits`). Only `tokenUsage` is read; + `inclusiveTokenUsage` also counts child sessions, which are folded on their own. +* **Tool results** are `tool_result` blocks with `is_error`, the same shape as Claude Code's + log; the live `PostToolUse` `tool_response` shape varies by tool, so shell status is read from + the text as on Claude Code (Droid reports no exit code field that the adapter relies on). +* **Tool pruning** is not available: the TUI has no persistent per-tool setting, and Factory + issue #65 asks for one (`droid exec --only-tools` is exec-only). +* **Not verified:** a fact written by the hook reaching a live Droid model. The hook output is + covered, the last hop is not. Also unverified: whether `droid exec` can be restricted to a + pure completion, which is why side calls are off unless `TE_DROID_SIDECALL=1`. + ## 4. State and privacy All state is local, under `$TOKENECONOMIZER_HOME` (default `~/.tokeneconomizer`), one directory diff --git a/plugin/.factory-plugin/plugin.json b/plugin/.factory-plugin/plugin.json new file mode 100644 index 0000000..644144f --- /dev/null +++ b/plugin/.factory-plugin/plugin.json @@ -0,0 +1,12 @@ +{ + "name": "tokeneconomizer", + "description": "Experience-driven cost control for Factory Droid: short repository facts learned from earlier sessions are injected at session start, each session is priced from Droid's own usage, and a guard redirects a turn stuck in a failing loop. Tool pruning is off on Droid (its TUI has no persistent tool list to change).", + "version": "0.2.0", + "author": { + "name": "TokenEconomizer authors" + }, + "homepage": "https://github.com/microsoft/TokenEconomizer", + "repository": "https://github.com/microsoft/TokenEconomizer", + "license": "Apache-2.0", + "keywords": ["cost", "tokens", "context", "hooks", "micro-skills", "failure-loops", "droid"] +} diff --git a/plugin/README.md b/plugin/README.md index faabd0e..458be30 100644 --- a/plugin/README.md +++ b/plugin/README.md @@ -1,14 +1,15 @@ # TokenEconomizer plugin How to install and use TokenEconomizer in Claude Code, Codex, the GitHub Copilot CLI, Pi, Cursor -(experimental), Grok Build (experimental), opencode (experimental), Hermes Agent (experimental) -and the Antigravity CLI (experimental). What the plugin does and why is in the [main README](../README.md); its -design and the verified behaviour of each agent are in [`docs/PLUGIN.md`](../docs/PLUGIN.md). +(experimental), Grok Build (experimental), opencode (experimental), Hermes Agent (experimental), +the Antigravity CLI (experimental) and Factory Droid (experimental). What the plugin does and +why is in the [main README](../README.md); its design and the verified behaviour of each agent are +in [`docs/PLUGIN.md`](../docs/PLUGIN.md). - [Before you start](#before-you-start) - Install and use it on [Claude Code](#claude-code) · [Codex](#codex) · [Copilot CLI](#copilot-cli) · [Pi](#pi) · [Cursor](#cursor) · [Grok Build](#grok-build) · [opencode](#opencode) · - [Hermes Agent](#hermes-agent) · [Antigravity CLI](#antigravity-cli) + [Hermes Agent](#hermes-agent) · [Antigravity CLI](#antigravity-cli) · [Factory Droid](#factory-droid) - [What to expect](#what-to-expect) · [Commands](#commands) · [Settings](#settings) - [Repository map](#repository-map) · [Using with rtk](#using-with-rtk) · [Model calls and API keys](#model-calls-and-api-keys) · [Troubleshooting](#troubleshooting) @@ -584,6 +585,45 @@ is off by default: `te --host antigravity config set microskills=envfacts`. proposer never runs, and the loop guard, which needs a judge's verdict to act, never redirects a turn. +## Factory Droid + +Experimental. Full notes are in [`docs/DROID.md`](../docs/DROID.md). + +**1. Install.** Droid needs a native plugin layout, so build the Droid bundle (it copies the +Python package into `lib/`, since Droid copies the plugin directory into its cache): + +```sh +python scripts/build_plugin.py --droid +droid plugin marketplace add "$PWD/dist/tokeneconomizer-droid" +droid plugin install tokeneconomizer@tokeneconomizer-droid --scope user +``` + +Start a new Droid session: hooks are snapshotted at startup. + +**2. Check it.** Run `/tokeneconomizer-status`, or from a terminal in the repository +`~/.tokeneconomizer/bin/te --host droid status`. + +**3. Import earlier sessions.** Droid's own logs are read by the same history import as the +other hosts: + +```sh +~/.tokeneconomizer/bin/te --host droid history import +``` + +It runs only into a repository with nothing folded yet and makes no model call. Start sessions +in the project's own folder: a directory that holds several repositories is learned as one. + +**What works.** Micro-skills, the loop guard and session cost. Droid writes each session's +`tokenUsage` to `.settings.json` beside its log, and the plugin prices the session +from it (an unknown model is labelled `fallback`). **What does not:** tool pruning, because the +TUI has no persistent per-tool setting (Factory issue #65). **Model calls** run through +`droid exec` and are off unless `TE_DROID_SIDECALL=1`: that command writes a session log of its +own, so enabling it is a deliberate choice. With it off, `microskills=envfacts` still learns. + +**Not verified yet:** a fact reaching a live Droid model. The hook output is covered by tests; +Droid appending it to the model's context is documented by Factory but has not been observed +here. + ## What to expect The plugin works session by session: what it learns from a repository's earlier sessions is diff --git a/plugin/hooks/droid-hooks.json b/plugin/hooks/droid-hooks.json new file mode 100644 index 0000000..2a54aab --- /dev/null +++ b/plugin/hooks/droid-hooks.json @@ -0,0 +1,57 @@ +{ + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "\"${DROID_PLUGIN_ROOT}/scripts/te-hook\" --host droid", + "timeout": 15 + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "\"${DROID_PLUGIN_ROOT}/scripts/te-hook\" --host droid", + "timeout": 20 + } + ] + } + ], + "PostToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "\"${DROID_PLUGIN_ROOT}/scripts/te-hook\" --host droid", + "timeout": 60 + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "\"${DROID_PLUGIN_ROOT}/scripts/te-hook\" --host droid", + "timeout": 10 + } + ] + } + ], + "SessionEnd": [ + { + "hooks": [ + { + "type": "command", + "command": "\"${DROID_PLUGIN_ROOT}/scripts/te-hook\" --host droid", + "timeout": 10 + } + ] + } + ] +} diff --git a/scripts/build_plugin.py b/scripts/build_plugin.py index e4fcdaa..db6488c 100644 --- a/scripts/build_plugin.py +++ b/scripts/build_plugin.py @@ -2,6 +2,7 @@ """Assemble a self-contained TokenEconomizer plugin bundle and a marketplace around it. python scripts/build_plugin.py [--out dist/marketplace] [--zip] + python scripts/build_plugin.py --droid [--out dist/tokeneconomizer-droid] Why a build step at all. Loaded in place -- `claude --plugin-dir plugin/`, or this repository added as a local-directory marketplace -- the plugin finds the core package one level up, in the @@ -54,14 +55,78 @@ def build(out): return plugin_out +def build_droid(out): + """The same bundle laid out as a native Factory Droid plugin and marketplace. + + Droid loads `hooks/hooks.json` in its own shape (events at the top level) and needs a + `.factory-plugin/plugin.json` to treat the directory as a native plugin; its slash commands + live in `commands/`, not Claude Code's `slash-commands/`. The upstream files stay as they + are: only the bundle is rewritten.""" + out = os.path.abspath(out) + plugin_out = os.path.join(out, "tokeneconomizer") + if os.path.exists(plugin_out): + shutil.rmtree(plugin_out) + # Only what Droid loads: the other agents' manifests and payloads would make the same + # directory look like a Claude, Codex or Pi plugin too, and Droid reads those layouts. + shutil.copytree(os.path.join(REPO, "plugin"), plugin_out, + ignore=shutil.ignore_patterns( + "__pycache__", "*.pyc", ".pytest_cache", "lib", ".DS_Store", + ".claude-plugin", ".codex-plugin", ".cursor-plugin", ".github", + "codex-skills", "hermes", "opencode", "pi", "slash-commands", + "package.json", "*.md.bak")) + shutil.copytree(os.path.join(REPO, "tokeneconomizer"), + os.path.join(plugin_out, "lib", "tokeneconomizer"), ignore=IGNORE) + # Droid's hook file: top-level events, `--host droid`, no PostToolUseFailure (Droid has no + # such event). + shutil.copyfile(os.path.join(REPO, "plugin", "hooks", "droid-hooks.json"), + os.path.join(plugin_out, "hooks", "hooks.json")) + # Slash commands become Droid commands. Droid has an Execute tool, not Claude's Bash, and + # expands ${DROID_PLUGIN_ROOT}. + commands_out = os.path.join(plugin_out, "commands") + os.makedirs(commands_out, exist_ok=True) + for name in sorted(os.listdir(os.path.join(REPO, "plugin", "slash-commands"))): + if not name.endswith(".md"): + continue + text = open(os.path.join(REPO, "plugin", "slash-commands", name), + encoding="utf-8").read() + text = text.replace("${CLAUDE_PLUGIN_ROOT}", "${DROID_PLUGIN_ROOT}") + text = text.replace("with the Bash tool", "with the Execute tool") + # Droid command names come straight from the filename and are not namespaced by plugin, + # so `status.md` would become `/status` next to Droid's own commands. + with open(os.path.join(commands_out, f"tokeneconomizer-{name}"), "w", + encoding="utf-8") as fh: + fh.write(text) + os.makedirs(os.path.join(out, ".factory-plugin"), exist_ok=True) + with open(os.path.join(out, ".factory-plugin", "marketplace.json"), "w", + encoding="utf-8") as fh: + json.dump({ + "name": "tokeneconomizer-droid", + "description": "TokenEconomizer for Factory Droid: learned repository facts, priced " + "sessions, and a failure-loop guard", + "owner": {"name": "TokenEconomizer authors"}, + "plugins": [{"name": "tokeneconomizer", "source": "./tokeneconomizer", + "description": "Micro-skills injected at session start, session cost " + "from Droid's own usage, and a guard for turns stuck in " + "a failing loop.", + "category": "productivity"}], + }, fh, indent=2) + fh.write("\n") + return plugin_out + + def main(argv=None): ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--out", default=os.path.join(REPO, "dist", "marketplace")) + ap.add_argument("--droid", action="store_true", + help="write a Factory Droid marketplace instead " + "(default : dist/tokeneconomizer-droid)") ap.add_argument("--zip", action="store_true", help="also write /../tokeneconomizer-plugin.zip of the plugin directory") args = ap.parse_args(argv) - plugin_out = build(args.out) + if args.droid and args.out == os.path.join(REPO, "dist", "marketplace"): + args.out = os.path.join(REPO, "dist", "tokeneconomizer-droid") + plugin_out = build_droid(args.out) if args.droid else build(args.out) print(f"plugin bundle: {plugin_out}") print(f"marketplace: {os.path.abspath(args.out)}") if args.zip: diff --git a/tests/test_droid.py b/tests/test_droid.py new file mode 100644 index 0000000..d2a9ef8 --- /dev/null +++ b/tests/test_droid.py @@ -0,0 +1,276 @@ +"""Factory Droid: the session reader, its host adapter, usage, and history import.""" +import json +import os +import time + +from tokeneconomizer import trace +from tokeneconomizer.plugin import accounting, denylist, history, hosts +from tokeneconomizer.plugin.context import LiveContext +from tokeneconomizer.plugin.paths import repo_identity + + +def _write_session(root, sid, cwd, calls, parent=None, workspace="ws", age_s=7200, + usage=None, model="claude-sonnet-5"): + """A synthetic Droid session: `calls` is [(tool, input, result_text, is_error)].""" + folder = os.path.join(str(root), workspace) + os.makedirs(folder, exist_ok=True) + path = os.path.join(folder, f"{sid}.jsonl") + head = {"type": "session_start", "id": sid, "cwd": cwd, "version": 2} + if parent: + head["callingSessionId"] = parent + lines = [head, + {"type": "message", "timestamp": "2026-01-01T00:00:00.000Z", "message": { + "role": "user", "content": [{"type": "text", "text": "run the tests"}]}}] + for i, (name, inp, res, is_error) in enumerate(calls): + tid = f"t{i}" + lines.append({"type": "message", "message": { + "role": "assistant", "modelId": model, + "content": [{"type": "tool_use", "id": tid, "name": name, "input": inp}]}}) + lines.append({"type": "message", "message": { + "role": "user", "content": [{"type": "tool_result", "tool_use_id": tid, + "is_error": is_error, "content": res}]}}) + with open(path, "w", encoding="utf-8") as fh: + fh.write("\n".join(json.dumps(x) for x in lines) + "\n") + old = time.time() - age_s + os.utime(path, (old, old)) + # Droid keeps usage beside the log. Only the sessions that ask for it have one. + if usage is not None: + settings = path[:-len(".jsonl")] + ".settings.json" + with open(settings, "w", encoding="utf-8") as fh: + json.dump({"model": model, "tokenUsage": usage}, fh) + os.utime(settings, (old, old)) + return path + + +FAIL = ("python: can't open file 'tests/runtests.py': [Errno 2] No such file or directory") +OK = "Ran 3 tests in 0.01s\nOK" +TEST_CALLS = [("Execute", {"command": "python tests/runtests.py"}, FAIL, True), + ("Execute", {"command": "python runtests.py"}, OK, False)] + +USAGE = {"inputTokens": 1200, "outputTokens": 400, "cacheCreationTokens": 3000, + "cacheReadTokens": 9000, "thinkingTokens": 20, "factoryCredits": 700} + + +def _env(state): + return {"TOKENECONOMIZER_HOME": str(state), "TE_FOLD_SYNC": "1", + "TE_MICROSKILLS": "envfacts", "TE_TOOLPRUNE": "off"} + + +# ---- the reader ----------------------------------------------------------------------------- +def test_reader_maps_droid_names_and_skips_reminders(tmp_path): + path = _write_session(tmp_path, "s1", str(tmp_path), TEST_CALLS) + # Injected reminders, a hidden message and a tool result are not prompts; the one typed + # prompt is. + assert trace.droid_prompt_count(path) == 1 + out = str(tmp_path / "claude.jsonl") + assert trace.droid_session_to_claude(path, out) == 2 + calls = trace.load_trajectory(out) + assert [c[0] for c in calls] == ["Bash", "Bash"] + assert calls[0][1]["command"] == "python tests/runtests.py" + assert calls[0][3] is False and calls[1][3] is True + assert trace.call_cwds(out) == [str(tmp_path), str(tmp_path)] + + +def test_started_is_epoch_seconds_in_the_head_and_in_the_row(tmp_path): + """`te history import` sorts and prints these with `time.localtime`: Droid's own ISO + timestamp there raised TypeError once the plan was printed.""" + path = _write_session(tmp_path / "sessions", "s1", str(tmp_path), TEST_CALLS) + head = trace.droid_log_head(path) + assert head["started"] == 1767225600.0 # 2026-01-01T00:00:00Z + row = trace.droid_sessions(str(tmp_path / "sessions"))[0] + assert row["started"] == head["started"] + + +def test_prompt_count_ignores_hidden_and_reminder_messages(tmp_path): + path = _write_session(tmp_path, "s2", str(tmp_path), []) + with open(path, "a", encoding="utf-8") as fh: + for msg in ({"role": "user", "visibility": "llm_only", + "content": [{"type": "text", "text": "a reminder"}]}, + {"role": "user", + "content": [{"type": "text", "text": "meta"}]}, + {"role": "user", "content": [{"type": "text", "text": "a real prompt"}]}): + fh.write(json.dumps({"type": "message", "message": msg}) + "\n") + assert trace.droid_prompt_count(path) == 2 # the fixture's own + the real one + + +# ---- the host --------------------------------------------------------------------------------- +def test_host_tool_mapping_and_usage(): + host = hosts.Droid() + assert host.name == "droid" + name, args, _res, code = host.tool_call({"tool_name": "Execute", "tool_input": { + "command": "pytest -q"}, "tool_response": {"output": "ok", "exitCode": 0}}) + assert (name, args["command"], code) == ("bash", "pytest -q", 0) + name, args, _res, _ = host.tool_call({"tool_name": "Create", "tool_input": { + "file_path": "/tmp/x"}, "tool_response": "written"}) + assert (name, args["path"]) == ("create", "/tmp/x") + name, _args, _res, _ = host.tool_call({"tool_name": "Edit", "tool_input": { + "file_path": "/tmp/x"}, "tool_response": "edited"}) + assert name == "edit" + # A log with no settings file beside it is not guessed at. + assert host.session_usage("/nonexistent")["input_tokens"] is None + assert accounting.effective(host.session_usage("/nonexistent")) == (None, "no_usage") + + +def test_usage_comes_from_the_settings_file_beside_the_log(tmp_path): + path = _write_session(tmp_path, "s1", str(tmp_path), TEST_CALLS, usage=USAGE) + usage = trace.droid_session_usage(path) + assert usage["input_tokens"] == 1200 + assert usage["cache_read_input_tokens"] == 9000 + assert usage["cache_creation_input_tokens"] == 3000 + assert usage["output_tokens"] == 400 + assert usage["model"] == "claude-sonnet-5" + assert usage["requests"] == 2 # two assistant messages + assert usage["factory_credits"] == 700 + effective, priced_by = accounting.effective(usage) + assert priced_by == "model" and effective > 0 + + +def test_an_unpriced_model_is_labelled_fallback(tmp_path): + path = _write_session(tmp_path, "s1", str(tmp_path), TEST_CALLS, usage=USAGE, + model="custom:cline-sub:glm-5-3-flash") + effective, priced_by = accounting.effective(trace.droid_session_usage(path)) + assert priced_by == "fallback" and effective > 0 + + +def test_detect_finds_droid_from_the_session_path(tmp_path): + tp = os.path.join(str(tmp_path), ".factory", "sessions", "ws", "s.jsonl") + assert isinstance(hosts.detect({"transcript_path": tp}, {}), hosts.Droid) + assert isinstance(hosts.detect({}, {"DROID_PLUGIN_ROOT": "/x"}), hosts.Droid) + + +def test_a_droid_shell_is_about_droid(tmp_path, monkeypatch): + for k in ("TE_HOST", "CLAUDECODE", "COPILOT_CLI", "CURSOR_AGENT", "CURSOR_PROJECT_DIR"): + monkeypatch.delenv(k, raising=False) + assert hosts.default_host({"FACTORY_SESSION_ID": "s"}) == "droid" + + +def test_tool_pruning_is_not_supported_for_droid(): + assert denylist.supports("droid") is False + + +def test_side_calls_are_off_unless_the_person_enables_them(tmp_path, monkeypatch): + from tokeneconomizer.plugin import sidecall + from tokeneconomizer.plugin.config import load_config + assert sidecall.droid_enabled({}) is False + assert sidecall.droid_enabled({"TE_DROID_SIDECALL": "1"}) is True + cfg = load_config(str(tmp_path), env={}) + monkeypatch.delenv("TE_DROID_SIDECALL", raising=False) + assert sidecall.runner_for("droid", cfg) is None + monkeypatch.setenv("TE_DROID_SIDECALL", "1") + assert callable(sidecall.runner_for("droid", cfg)) + + +def test_the_settings_usage_is_normalized_for_pricing(): + from tokeneconomizer.plugin import sidecall + usage = sidecall._droid_usage({"inputTokens": 5, "outputTokens": 7, + "cacheReadTokens": 11, "cacheCreationTokens": 3}) + assert usage["input_tokens"] == 5 and usage["output_tokens"] == 7 + assert usage["cache_read_input_tokens"] == 11 + assert usage["cache_creation_input_tokens"] == 3 + assert sidecall._droid_usage({"outputTokens": 7}) is None + + +# ---- history import --------------------------------------------------------------------------- +def _droid_ctx(env, repo): + return LiveContext.build(hosts.Droid(), str(repo), env) + + +def test_history_lists_droid_sessions_and_leaves_subagents_out(tmp_path): + sessions = tmp_path / "sessions" + repo = tmp_path / "repo" + repo.mkdir() + _write_session(sessions, "old", str(repo), TEST_CALLS) + _write_session(sessions, "sub", str(repo), TEST_CALLS, parent="old") + rows = hosts.Droid().history({"DROID_SESSIONS_DIR": str(sessions)}) + assert [r["session_id"] for r in rows] == ["old"] + assert rows[0]["folder"] == "ws" + + +def test_history_import_learns_facts_and_prices_the_sessions(tmp_path): + state, sessions = tmp_path / "state", tmp_path / "sessions" + repo = tmp_path / "repo" + repo.mkdir() + (repo / "runtests.py").write_text("print('OK')\n") + for i in range(5): + _write_session(sessions, f"s{i}", str(repo), TEST_CALLS, age_s=7200 + i * 60, + usage=USAGE) + env = dict(_env(state), DROID_SESSIONS_DIR=str(sessions)) + ctx = _droid_ctx(env, repo) + assert len(history.plan(ctx)["sessions"]) == 5 + res = history.run(ctx, now=time.time()) + assert res["state"] == "done" and res["imported"] == 5 + + assert history.describe(history.summary(ctx), "droid").startswith( + "read 5 earlier session(s) from Droid's history") + + injected = ctx.store.read("injection.json") + assert injected["profile"] == "envfacts" + assert injected["sessions"] == 5 + assert "`python runtests.py" in injected["rendered"] + + rows = [r for r in ctx.store.read_lines("ledger.jsonl") if r.get("kind") == "session"] + assert len(rows) == 5 + assert all(r["host"] == "droid" and r["prompts"] == 1 for r in rows) + assert all(r["effective_tokens"] and r["priced_by"] == "model" for r in rows) + + +def test_history_plan_skips_subagents_and_sessions_still_being_written(tmp_path): + state, sessions = tmp_path / "state", tmp_path / "sessions" + repo, other = tmp_path / "repo", tmp_path / "other" + repo.mkdir() + other.mkdir() + _write_session(sessions, "mine", str(repo), TEST_CALLS) + _write_session(sessions, "sub", str(repo), TEST_CALLS, parent="mine") + _write_session(sessions, "live", str(repo), TEST_CALLS, age_s=0) + _write_session(sessions, "elsewhere", str(other), TEST_CALLS) + env = dict(_env(state), DROID_SESSIONS_DIR=str(sessions)) + plan = history.plan(_droid_ctx(env, repo)) + assert [s["id"] for s in plan["sessions"]] == ["mine"] + assert plan["skipped"] == {"recent": 1} + + +def test_the_session_start_hook_returns_the_learned_fact(tmp_path): + """The offline end to end: five finished sessions are imported, and the next session + start's own stdout carries the fact Droid appends to the model's context.""" + from test_live_hooks import call + + state, sessions = tmp_path / "state", tmp_path / "sessions" + repo = tmp_path / "repo" + repo.mkdir() + (repo / "runtests.py").write_text("print('OK')\n") + for i in range(5): + _write_session(sessions, f"s{i}", str(repo), TEST_CALLS, age_s=7200 + i * 60) + env = dict(_env(state), DROID_SESSIONS_DIR=str(sessions)) + assert history.run(_droid_ctx(env, repo), now=time.time())["imported"] == 5 + + transcript = str(sessions / "ws" / "s5.jsonl") + out = call(env, {"session_id": "s5", "transcript_path": transcript, "cwd": str(repo), + "hook_event_name": "SessionStart", "source": "startup"}, "droid") + assert "`python runtests.py" in out["hookSpecificOutput"]["additionalContext"] + + +# ---- packaging -------------------------------------------------------------------------------- +def test_droid_plugin_packaging(tmp_path): + repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + manifest = os.path.join(repo_root, "plugin", ".factory-plugin", "plugin.json") + assert json.load(open(manifest))["name"] == "tokeneconomizer" + hooks = json.load(open(os.path.join(repo_root, "plugin", "hooks", "droid-hooks.json"))) + assert hooks["SessionStart"][0]["hooks"][0]["command"].endswith("--host droid") + assert "PostToolUseFailure" not in hooks # Droid has no such event + + import importlib.util + spec = importlib.util.spec_from_file_location( + "build_plugin", os.path.join(repo_root, "scripts", "build_plugin.py")) + build_plugin = importlib.util.module_from_spec(spec) + spec.loader.exec_module(build_plugin) + out = tmp_path / "marketplace" + build_plugin.build_droid(str(out)) + plugin = out / "tokeneconomizer" + built_hooks = json.load(open(plugin / "hooks" / "hooks.json")) + assert set(built_hooks) == {"SessionStart", "UserPromptSubmit", "PostToolUse", "Stop", + "SessionEnd"} + command = (plugin / "commands" / "tokeneconomizer-status.md").read_text() + assert "${DROID_PLUGIN_ROOT}" in command and "Execute tool" in command + assert (plugin / "lib" / "tokeneconomizer" / "plugin" / "hook.py").exists() + assert json.load(open(out / ".factory-plugin" / "marketplace.json"))["plugins"][0][ + "name"] == "tokeneconomizer" diff --git a/tests/test_live_core.py b/tests/test_live_core.py index 4dae487..4e05d61 100644 --- a/tests/test_live_core.py +++ b/tests/test_live_core.py @@ -265,7 +265,8 @@ def test_from_a_plain_terminal_te_is_about_the_agent_that_last_worked_here(tmp_p Code state of a repository only Codex (or Pi) had worked in.""" from tokeneconomizer.plugin import cli from tokeneconomizer.plugin.paths import repo_identity, repo_state_dir - for k in ("TE_HOST", "CLAUDECODE", "COPILOT_CLI"): + for k in ("TE_HOST", "CLAUDECODE", "COPILOT_CLI", "FACTORY_SESSION_ID", + "DROID_PLUGIN_ROOT", "CURSOR_AGENT", "CURSOR_PROJECT_DIR"): monkeypatch.delenv(k, raising=False) monkeypatch.setenv("TOKENECONOMIZER_HOME", str(tmp_path / "te")) work = tmp_path / "work" diff --git a/tests/test_live_hooks.py b/tests/test_live_hooks.py index 2149747..cd1089c 100644 --- a/tests/test_live_hooks.py +++ b/tests/test_live_hooks.py @@ -332,6 +332,11 @@ def _stuck(host, n=40): if host in ("claude", "codex"): return stuck_calls(n, host if host == "codex" else None) fail = "FAILED tests/test_a.py::test_x - AssertionError: assert 1 == 2" + if host == "droid": + return [("Edit", {"file_path": "/r/pkg/a.py", "old_string": "x", "new_string": "y"}, + {"output": "ok", "exitCode": 0}) if k % 2 == 0 else + ("Execute", {"command": "pytest -q"}, {"output": fail, "exitCode": 1}) + for k in range(n)] if host == "pi": return [("edit", {"path": "/r/pkg/a.py", "oldText": "x", "newText": "y"}, {"content": "ok"}) if k % 2 == 0 else ("bash", {"command": "pytest -q"}, @@ -345,7 +350,7 @@ def _stuck(host, n=40): for k in range(n)] -@pytest.mark.parametrize("host", ["claude", "codex", "copilot", "pi"]) +@pytest.mark.parametrize("host", ["claude", "codex", "copilot", "pi", "droid"]) @pytest.mark.parametrize("mode", ["interactive", "headless"]) @pytest.mark.parametrize("setting", ["nudge", "stop"]) def test_the_guard_redirects_then_tells_the_person_how_to_stop_on_every_agent( @@ -363,10 +368,18 @@ def test_the_guard_redirects_then_tells_the_person_how_to_stop_on_every_agent( lambda o: o.get("systemMessage") or "") to_model = [ctx_key(o) for o in outs if ctx_key(o)] stuck = [shown(o) for o in outs if "still looks stuck" in shown(o)] + assert not any("continue" in o or "stopReason" in o for o in outs) # never a stop + if host == "droid": + # Droid documents no `systemMessage`: the notice is relayed through the model's reply, + # the channel it actually shows. + assert stuck == [] + relay = [t for t in to_model if t and "still looks stuck" in t] + assert len(relay) == 1 and "it does not change your task" in relay[0] + assert any("Process check (automated)" in (t or "") for t in to_model) + return assert len(stuck) == 1 # one notice hint = hosts.HOSTS[host]().stop_hint(mode == "headless") assert stuck[0].endswith(hint) - assert not any("continue" in o or "stopReason" in o for o in outs) # never a stop if host == "claude": # Claude Code keeps hook messages out of the main view, so the notice also goes to the # model, word for word, to pass on in its reply; the redirect comes first. diff --git a/tokeneconomizer/plugin/cli.py b/tokeneconomizer/plugin/cli.py index 77a9b67..5859655 100644 --- a/tokeneconomizer/plugin/cli.py +++ b/tokeneconomizer/plugin/cli.py @@ -19,8 +19,8 @@ te config unset KEY ... back to the next source down (the global file, then default) te repomap [show | refresh] the repository map (`repomap=on`): what is cached, or make it now - te history import [--dry-run] Claude Code: fold this repository's sessions from before - the plugin, from Claude Code's own logs (no model calls, + te history import [--dry-run] fold this repository's sessions from before the plugin, + from the agent's own logs (no model calls, no deny list written; only before any session is folded; with `history_import=on`, the first session start runs it by itself) @@ -264,7 +264,7 @@ def earlier(n): f"{accounting.fmt_tokens(ledger['effective_tokens'])} effective tokens, plugin " f"overhead {accounting.fmt_tokens(ledger['overhead_tokens'])} " f"({ledger['overhead_frac']:.1%})") - hist = history.describe(out["history"]) + hist = history.describe(out["history"], ctx.host.name) if hist: print(f" history {hist}") i = out["microskills"] @@ -654,7 +654,7 @@ def _print_history(ctx, doc): print(f"no import of earlier sessions recorded for {ctx.repo.label}" + ("" if ctx.config.history_import else " (history_import is off)")) return - print(f"{ctx.repo.label}: {history.describe(doc) or doc.get('state')}") + print(f"{ctx.repo.label}: {history.describe(doc, ctx.host.name) or doc.get('state')}") skipped = doc.get("skipped") if isinstance(doc.get("skipped"), dict) else {} if doc.get("over_limit"): skipped = dict(skipped, older_than_the_newest=doc["over_limit"]) @@ -663,13 +663,13 @@ def _print_history(ctx, doc): def cmd_history(args): - """`te history [import]`: a repository's sessions from before the plugin (Claude Code).""" + """`te history [import]`: a repository's sessions from before the plugin.""" ctx = _ctx(args) if args.action == "status" or args.status: _print_history(ctx, history.summary(ctx)) return 0 if not ctx.host.keeps_history: - print(f"{ctx.host.name}: no session history the plugin reads (Claude Code only)") + print(f"{ctx.host.name}: no session history the plugin reads") return 1 if args.dry_run: p = history.plan(ctx) @@ -813,13 +813,13 @@ def cmd_doctor(args): ok = False import shutil as _sh # Only the agents installed here: a Codex-only machine has no Claude Code catalog to probe. - agents = [h for h in ("antigravity", "claude", "codex", "copilot", "grok", "hermes", "opencode", - "pi") + agents = [h for h in ("antigravity", "claude", "codex", "copilot", "droid", "grok", "hermes", + "opencode", "pi") if _sh.which(sidecall.claude_cli() if h == "claude" else sidecall.hermes_cli() if h == "hermes" else "agy" if h == "antigravity" else h)] print("agents " + (', '.join(agents) or 'none of antigravity (agy), claude, codex, ' - 'copilot, grok, hermes, opencode, pi on PATH')) + 'copilot, droid, grok, hermes, opencode, pi on PATH')) for h in agents: pause = sidecall.paused(h, root) if pause: @@ -853,6 +853,13 @@ def cmd_doctor(args): if ctrust is not None: print(f"copilot this folder is {'trusted' if ctrust else 'NOT trusted'} (an " f"untrusted folder's .github/copilot settings are ignored)") + if "droid" in agents: + sessions = os.environ.get("DROID_SESSIONS_DIR") or os.path.join( + os.path.expanduser("~"), ".factory", "sessions") + side = ("on (TE_DROID_SIDECALL=1)" if sidecall.droid_enabled() + else "off (set TE_DROID_SIDECALL=1 to try them)") + print(f"droid sessions {sessions}; side calls {side}; tool pruning is not " + f"available in the TUI") if "hermes" in agents: link = os.path.join(sidecall.hermes_home(), "plugins", "tokeneconomizer") print(f"hermes plugin {'linked at ' + link if os.path.exists(link) else 'not in ' + link}" diff --git a/tokeneconomizer/plugin/history.py b/tokeneconomizer/plugin/history.py index 0b37827..95067b9 100644 --- a/tokeneconomizer/plugin/history.py +++ b/tokeneconomizer/plugin/history.py @@ -155,14 +155,13 @@ def _identity(cwd, env, cache): def is_this_repository(ctx, folder, cwd, cache=None): """Is a log filed under `folder` that names `cwd` a session of this repository? Its recorded - directory decides, keyed as a live session's is; only where that directory is gone, an - exact match of the folder with the one Claude Code derives from the repository's root.""" + directory decides, keyed as a live session's is; only where that directory is gone, the + host's own folder rule decides (Claude Code's derived project folder, Droid's encoded + workspace name).""" cache = {} if cache is None else cache if cwd and os.path.isdir(cwd): return _identity(cwd, ctx.env, cache) == ctx.repo.key - root = ctx.repo.root - names = {trace.claude_project_folder(root), trace.claude_project_folder(os.path.realpath(root))} - return folder in names + return ctx.host.folder_matches(ctx.repo.root, folder) def _headless(head, prompts): @@ -184,10 +183,10 @@ def plan(ctx, now=None): def skip(why): skipped[why] = skipped.get(why, 0) + 1 for log in ctx.host.history(ctx.env, cfg.history_dirs): - head = trace.claude_log_head(log["path"]) + head = ctx.host.log_head(log["path"]) if not is_this_repository(ctx, log["folder"], head.get("cwd"), cache): continue - sid = trace.claude_safe_session_id(log["session_id"]) + sid = ctx.host.safe_session_id(log["session_id"]) mtime, size = log["mtime"], log["size"] if sid is None: skip("unsafe_id") @@ -202,7 +201,7 @@ def skip(why): elif os.path.exists(ctx.store.path("sessions", f"{sid}.json")): skip("recorded_live") else: - prompts = trace.claude_prompt_count(log["path"]) + prompts = ctx.host.prompt_count(log["path"]) or 0 if prompts <= 0: skip("no_prompt") elif _headless(head, prompts): @@ -217,7 +216,7 @@ def skip(why): def _still_eligible(ctx, item): """Why a planned session is no longer to be imported (a resumed plan), or None.""" - sid = trace.claude_safe_session_id(item.get("id")) if isinstance(item, dict) else None + sid = ctx.host.safe_session_id(item.get("id")) if isinstance(item, dict) else None path = item.get("path") if isinstance(item, dict) else None if sid is None or not isinstance(path, str) or os.path.basename(path) != f"{sid}.jsonl": return "unsafe_id" @@ -250,12 +249,12 @@ def replay_envelope(ctx, transcript_path): level, overrides = cfg.loopguard_aggr, cfg.loopguard_thresholds thr = loopguard.base_thresholds(level, overrides) recorded = 0 - turns = trace.claude_prompt_turns(transcript_path) + turns = ctx.host.prompt_turns(transcript_path) turn = next(turns, None) while turn is not None: following = next(turns, None) guard = loopguard.TurnGuard() - for name, args, result, ok, _cwd in trace.claude_calls(turn): + for name, args, result, ok, _cwd in ctx.host.turn_calls(turn): result = (result or "")[:RESULT_CAP] if ok: code = claude_success_code(name, args) @@ -264,8 +263,8 @@ def replay_envelope(ctx, transcript_path): code = int(m.group(1)) if m else None guard.fold(name, args if isinstance(args, dict) else {}, result, code, envelope=None, thr=thr, judge=None) - normal = not trace.claude_turn_interrupted(turn) and ( - following is not None or trace.claude_turn_answered(turn)) + normal = not ctx.host.turn_interrupted(turn) and ( + following is not None or ctx.host.turn_answered(turn)) if guard.cum_turns and normal: loopguard.record_turn(ctx.store, guard, level=level, overrides=overrides) recorded += 1 @@ -496,7 +495,7 @@ def mark(d): if _update(ctx, mark, timeout=HISTORY_LOCK_S) is None or not out.get("n"): return None return (f"TokenEconomizer read {out['n']} earlier session(s) of {ctx.repo.label} from " - f"Claude Code's history.") + f"{HOST_LABEL.get(ctx.host.name, 'Claude Code')}'s history.") def summary(ctx): @@ -510,16 +509,23 @@ def summary(ctx): return out -def describe(doc): +# The agent's name as a person reads it, for the lines that name whose logs were read. +HOST_LABEL = {"claude": "Claude Code", "codex": "Codex", "copilot": "Copilot CLI", "pi": "Pi", + "opencode": "opencode", "cursor": "Cursor", "hermes": "Hermes Agent", + "droid": "Droid"} + + +def describe(doc, host="claude"): """One line for `te status`: what the import did here.""" if not doc: return None state = doc.get("state") n = int(_num(doc.get("imported"))) + who = HOST_LABEL.get(str(host or "claude"), "Claude Code") if state == "done": - return f"read {n} earlier session(s) from Claude Code's history" + return f"read {n} earlier session(s) from {who}'s history" if state in fold.HISTORY_BLOCKING: - return (f"reading Claude Code's history: {n} session(s) so far, {doc.get('left', '?')} " + return (f"reading {who}'s history: {n} session(s) so far, {doc.get('left', '?')} " f"to go; sessions ending meanwhile are folded after it") if state == "skipped": return f"not read ({doc.get('reason') or 'skipped'})" diff --git a/tokeneconomizer/plugin/hosts.py b/tokeneconomizer/plugin/hosts.py index 556d40e..5351a9e 100644 --- a/tokeneconomizer/plugin/hosts.py +++ b/tokeneconomizer/plugin/hosts.py @@ -229,6 +229,31 @@ def history(self, env, extra_dirs=()): the plugin reads.""" return [] + # -- the session-log details the history import reads --------------------------------- + def log_head(self, path): + """`{cwd, started, entrypoint?}` for a session log: the header the import filters on.""" + return trace.claude_log_head(path) + + def safe_session_id(self, sid): + return trace.claude_safe_session_id(sid) + + def prompt_turns(self, path): + return trace.claude_prompt_turns(path) + + def turn_calls(self, turn_lines): + return trace.claude_calls(turn_lines) + + def turn_interrupted(self, turn_lines): + return trace.claude_turn_interrupted(turn_lines) + + def turn_answered(self, turn_lines): + return trace.claude_turn_answered(turn_lines) + + def folder_matches(self, root, folder): + """Does a log's folder name this repository's root, when the log's cwd is gone?""" + return folder in {trace.claude_project_folder(root), + trace.claude_project_folder(os.path.realpath(root))} + def _failure_response(p): """A PostToolUseFailure payload's `error` as a response; None for any other payload. A @@ -1191,8 +1216,109 @@ def _ancestor_args(limit=4): return out -HOSTS = {"claude": ClaudeCode, "codex": Codex, "copilot": Copilot, "opencode": OpenCode, - "pi": Pi, "cursor": Cursor, "hermes": Hermes, "grok": Grok, +_DROID_SHELL = {"execute", "script"} +_DROID_EDIT = {"edit", "multiedit", "str_replace"} +_DROID_CREATE = {"create", "write"} + + +class Droid(Host): + """Factory Droid, through `plugin/.factory-plugin/` and its `hooks/hooks.json`. + + Droid runs the Claude-shaped hook contract (`session_id`, `transcript_path`, `cwd`, + `hook_event_name`) with its own tool names, and documents the same context channels: + `hookSpecificOutput.additionalContext` from SessionStart, `additionalContext` from + UserPromptSubmit and PostToolUse. It documents no `systemMessage` and no + `PostToolUseFailure`: a failed tool is a `tool_result.is_error` block in the session log, + and a hook message is only visible in Droid's detailed transcript view. Notices therefore + go through the model (`relays_notices`), never into the person's context as plugin chatter. + + The session log is `~/.factory/sessions//.jsonl`; its blocks are + Claude Code's, so `trace.droid_session_to_claude` unwraps the message records and the + existing readers take it from there. Usage lives beside it in `.settings.json` + (`trace.droid_session_usage`), so a folded session is priced like any other. + + The TUI has no persistent per-tool setting (`droid exec --only-tools` has no interactive + equivalent; Factory issue #65), so tool pruning is not available here: `denylist.supports` + says so and the controller writes nothing. + """ + name = "droid" + # Droid documents no interrupt key beyond its own UI; this is the generic instruction. + interrupt = "Interrupt the agent to stop it." + prompt_context = True # documented: UserPromptSubmit additionalContext + relays_notices = True + keeps_history = True + + def notice(self, text): + # No `systemMessage` in Droid's contract; stdout of a hook is only in the detailed + # transcript view. The controller relays an abort through the model when + # `relays_notices` is set, so this stays empty rather than adding plugin chatter. + return {} + + def env_session_id(self, env): + return str(env.get("TE_SESSION_ID") or env.get("FACTORY_SESSION_ID") or "") + + def tool_call(self, p): + name, args, text, code = super().tool_call(p) + low = name.lower() + if low in _DROID_SHELL: + cmd = args.get("command", args.get("script", "")) + return "bash", {"command": str(cmd or "")}, text, code + if low in _DROID_CREATE or low in _DROID_EDIT: + path = args.get("file_path") or args.get("path") or "" + return ("create" if low in _DROID_CREATE else "edit"), dict(args, path=path), text, code + if low in ("applypatch", "apply_patch"): + patch = next((v for v in (args.get("input"), args.get("patch"), args.get("command")) + if isinstance(v, str)), "") + paths = _PATCH_FILE_RE.findall(patch) + return "edit", {"path": paths[0] if paths else "", "paths": paths}, text, code + return name, args, text, code + + def headless(self, env, transcript=None): + # Droid does not publish a mode marker on the hook's environment; a TUI session is the + # default, and the wrong guess only changes the wording of an abort notice. + return False + + def mechanism_trace(self, transcript_path, workdir): + out = os.path.join(workdir, "droid_trace.jsonl") + n = trace.droid_session_to_claude(transcript_path, out) + return out if n is not None else None + + def session_usage(self, transcript_path): + return trace.droid_session_usage(transcript_path) + + def prompt_count(self, transcript_path): + return trace.droid_prompt_count(transcript_path) + + # -- the history import -------------------------------------------------------------- + def history(self, env, extra_dirs=()): + """Every top-level session under `$DROID_SESSIONS_DIR` (else `~/.factory/sessions`), + newest last. Subagent sessions are left out: they carry `callingSessionId` and are part + of the session that launched them.""" + rows = [] + for row in trace.droid_sessions(None, env): + if row.get("parent"): + continue + rows.append({"session_id": row["id"], "folder": row.get("folder"), + "path": row["path"], "mtime": row["mtime"], "size": row["size"]}) + return rows + + def log_head(self, path): + return trace.droid_log_head(path) + + def safe_session_id(self, sid): + return trace.droid_safe_session_id(sid) + + def prompt_turns(self, path): + return trace.droid_prompt_turns(path) + + def folder_matches(self, root, folder): + # Droid files a session under its workspace path with every separator written as `-`: + # `/Users/me/repo` becomes `-Users-me-repo`. Only asked when the recorded cwd is gone. + return folder == os.path.abspath(root).replace("\\", "-").replace("/", "-") + + +HOSTS = {"claude": ClaudeCode, "codex": Codex, "copilot": Copilot, "droid": Droid, + "opencode": OpenCode, "pi": Pi, "cursor": Cursor, "hermes": Hermes, "grok": Grok, "antigravity": Antigravity} @@ -1212,6 +1338,10 @@ def default_host(env=None, recent=None): return "cursor" if env.get("GROK_SESSION_ID"): return "grok" + # Droid marks its shell with the session it runs; checked last, so an agent started from + # inside Droid is read as its own host. + if env.get("FACTORY_SESSION_ID") or env.get("DROID_PLUGIN_ROOT"): + return "droid" return recent if recent in HOSTS else "claude" @@ -1237,6 +1367,11 @@ def detect(payload, env=None, hint=None): return Codex() if "/session-state/" in sp and tp.endswith("events.jsonl"): return Copilot() + # Droid's sessions live under `.factory/sessions//.jsonl`; its plugin hooks + # also mark the environment. The transcript test comes first, so a Droid session started + # from inside another agent is still Droid's. + if "/.factory/sessions/" in sp or (not tp and (env or {}).get("DROID_PLUGIN_ROOT")): + return Droid() # Claude Code always sends a transcript path; Copilot sends none before Stop, and marks its # hook processes. (Hooks inherit the parent's environment, so the marker alone would also # tag a Claude Code session started from inside Copilot -- hence the transcript test first.) diff --git a/tokeneconomizer/plugin/sidecall.py b/tokeneconomizer/plugin/sidecall.py index f30c114..54214b3 100644 --- a/tokeneconomizer/plugin/sidecall.py +++ b/tokeneconomizer/plugin/sidecall.py @@ -44,6 +44,11 @@ the person's default model. Never `hermes -z`, which auto-approves dangerous commands: a side call's prompt holds text a repository controls, and this form offers it no tool. See `hermes_call` for why `TE_SIDECALL` is not how it avoids the plugin's hooks. + * Factory Droid: `droid exec --output-format json --disable-builtin-skills`, in a throwaway + directory at Droid's default read-only autonomy, on the person's default model. OFF unless + `TE_DROID_SIDECALL=1`: Droid writes a session log of its own for the call, and has no + documented flags for a pure completion (no tools, no hooks, no log). Its usage is read from + the call's result or from the settings file beside that log. Every side call but Hermes's runs with `TE_SIDECALL=1`, which the plugin's hook entry point treats as "do nothing": without it the nested agent would run this plugin's hooks and recurse. A call that @@ -742,9 +747,107 @@ def copilot_call(prompt, model="", cli="copilot", timeout=TIMEOUT_S): # Hosts the plugin makes no model call for: their CLI cannot run a prompt with every tool off. NO_SIDE_CALLS = frozenset({"antigravity"}) + +def droid_enabled(env=None): + """Whether Droid side calls are allowed. They are OFF by default: `droid exec` writes a + session log of its own under `~/.factory/sessions`, so a person enables them knowingly.""" + env = os.environ if env is None else env + return str(env.get("TE_DROID_SIDECALL") or "").strip().lower() in ("1", "true", "yes", "on") + + +def _droid_sessions_root(): + return os.environ.get("DROID_SESSIONS_DIR") or os.path.join( + os.path.expanduser("~"), ".factory", "sessions") + + +def _droid_usage(d): + """A Droid usage object (camelCase from its settings, snake_case from `exec`) as the + breakdown `accounting.effective` reads.""" + if not isinstance(d, dict): + return None + + def key(camel, snake): + return d.get(snake, d.get(camel)) + usage = {"input_tokens": key("inputTokens", "input_tokens"), + "cache_read_input_tokens": key("cacheReadTokens", "cache_read_input_tokens"), + "cache_creation_input_tokens": key("cacheCreationTokens", + "cache_creation_input_tokens"), + "output_tokens": key("outputTokens", "output_tokens"), + "model": d.get("model")} + return usage if usage["input_tokens"] is not None else None + + +def _droid_settings_usage(sid): + """The usage Droid wrote beside a side call's own session log, or None.""" + if not sid: + return None + root = _droid_sessions_root() + try: + folders = os.listdir(root) + except OSError: + return None + for folder in folders: + path = os.path.join(root, folder, f"{sid}.settings.json") + try: + with open(path, encoding="utf-8") as fh: + data = json.load(fh) + except (OSError, ValueError): + continue + return _droid_usage(dict(data.get("tokenUsage") or {}, model=data.get("model"))) + return None + + +def droid_call(prompt, model="", cli="droid", timeout=TIMEOUT_S): + """A text-only completion through `droid exec`, in a throwaway directory. + + Droid has no documented flags for a pure completion (no tools, no hooks, no session log), + so this is off unless `TE_DROID_SIDECALL=1`. `TE_SIDECALL=1` makes this plugin's own hooks + no-ops inside the nested session, so a side call cannot recurse; the default read-only + autonomy means it cannot write either. Its own usage is read from the `exec` result or from + the settings file beside the session it wrote, so the call is charged rather than guessed.""" + cmd = [cli, "exec", "--output-format", "json", "--disable-builtin-skills", + "--append-system-prompt", + "Answer with text only. Do not call any tool. Your entire reply is the answer."] + if model: + cmd += ["--model", model] + cmd.append(prompt) + with tempfile.TemporaryDirectory(prefix="te-side-") as cwd: + out = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", + errors="replace", timeout=timeout, env=_env(), cwd=cwd, + stdin=subprocess.DEVNULL) + text, sid, usage = "", "", None + for line in (out.stdout or "").splitlines(): + try: + ev = json.loads(line) + except ValueError: + continue + if not isinstance(ev, dict): + continue + sid = ev.get("session_id") or sid + data = ev.get("data") if isinstance(ev.get("data"), dict) else {} + usage = _droid_usage(ev.get("usage")) or _droid_usage(data.get("usage")) or usage + for key in ("result", "text", "message"): + if isinstance(ev.get(key), str) and ev[key].strip(): + text = ev[key] + for key in ("content", "result", "text"): + if isinstance(data.get(key), str) and data[key].strip(): + text = data[key] + if not text and (out.stdout or "").strip(): + # `--output-format json` that answers plain text (an older build): take it as-is. + text = out.stdout.strip() + if not text: + _no_reply("droid", out) + eff = None + usage = usage or _droid_settings_usage(sid) + if usage: + usage.setdefault("model", model or None) + eff, _ = accounting.effective(usage) + return text, eff + + # The command each agent's side calls run, as the notices name it; the rest run ` -p`. -_CALLS = {"codex": "codex exec", "opencode": "opencode run", "cursor": "cursor-agent -p", - "hermes": "hermes chat"} +_CALLS = {"codex": "codex exec", "droid": "droid exec", "opencode": "opencode run", + "cursor": "cursor-agent -p", "hermes": "hermes chat"} def call_name(host_name): @@ -783,4 +886,10 @@ def _host_call(host_name, config): return lambda prompt, _m="agent": codex_call(prompt, model=model) if host_name == "copilot": return lambda prompt, _m="agent": copilot_call(prompt, model=model) + if host_name == "droid": + # Off unless the person turned it on: see `droid_enabled`. No runner means the free + # layer still learns; only the paid proposer and judge wait. + if not droid_enabled(): + return None + return lambda prompt, _m="agent": droid_call(prompt, model=model) return lambda prompt, _m="agent": claude_call(prompt, model=model or "haiku") diff --git a/tokeneconomizer/trace/__init__.py b/tokeneconomizer/trace/__init__.py index b5c380a..f9d0de0 100644 --- a/tokeneconomizer/trace/__init__.py +++ b/tokeneconomizer/trace/__init__.py @@ -6,9 +6,9 @@ * `calls` -- `load_trajectory`: any agent's log as ordered `(name, args, result, ok)` calls; `call_cwds`, `tool_usage`, `strip_rtk_prefix`; * `catalog` -- the tool list a session loaded, from whichever source the agent publishes; - * `antigravity`, `claude`, `codex`, `copilot`, `grok`, `hermes`, `opencode`, `pi` -- each agent's - own session file: usage, metadata, prompt-turns, and the conversions that let - the readers above handle it. + * `antigravity`, `claude`, `codex`, `copilot`, `cursor`, `droid`, `grok`, `hermes`, + `opencode`, `pi` -- each agent's own session file: usage, metadata, + prompt-turns, and the conversions that let the readers above handle it. Stdlib only. """ @@ -30,6 +30,9 @@ from .antigravity import (antigravity_prompt_count, antigravity_read, antigravity_session_to_claude, antigravity_session_usage) from .cursor import cursor_events_to_claude, cursor_prompt_count +from .droid import (droid_log_head, droid_prompt_count, droid_prompt_turns, + droid_safe_session_id, droid_session_header, droid_session_to_claude, + droid_session_usage, droid_sessions) from .grok import (grok_json_reply, grok_prompt_count, grok_result, grok_session_to_claude, grok_session_usage, grok_tool) from .hermes import (hermes_export, hermes_find_session, hermes_prompt_count, hermes_result, @@ -56,6 +59,8 @@ "antigravity_prompt_count", "antigravity_read", "antigravity_session_to_claude", "antigravity_session_usage", "cursor_events_to_claude", "cursor_prompt_count", + "droid_log_head", "droid_prompt_count", "droid_prompt_turns", "droid_safe_session_id", + "droid_session_header", "droid_session_to_claude", "droid_session_usage", "droid_sessions", "grok_json_reply", "grok_prompt_count", "grok_result", "grok_session_to_claude", "grok_session_usage", "grok_tool", "hermes_export", "hermes_find_session", "hermes_prompt_count", "hermes_result", diff --git a/tokeneconomizer/trace/droid.py b/tokeneconomizer/trace/droid.py new file mode 100644 index 0000000..3160c34 --- /dev/null +++ b/tokeneconomizer/trace/droid.py @@ -0,0 +1,340 @@ +"""Factory Droid: a session stored as one JSONL file under `~/.factory/sessions/`. + +Each line is a record with a `type`: + + * `session_start` -- the header: `id`, `cwd`, `version`, `title`, `owner`. This is the only + place a session says which repository it belongs to. + * `message` -- one message under `message`: `role`, `content` (the Claude Code block + list: `text`, `thinking`, `tool_use`, `tool_result`), `modelId`. Tool calls carry + `{type, id, name, input}`; results carry `{type, tool_use_id, is_error, content}`. + * `agent_turn_outcome`, `todo_state`, `compaction_state` -- bookkeeping, ignored here. + +The format is close enough to a Claude Code transcript that a session is read by unwrapping +each `message` record into a `{"type": role, "message": ...}` line, which the readers already +handle; `droid_session_to_claude` writes that file. + +Usage is not in the transcript. Droid keeps it beside the log, in `.settings.json`: +`tokenUsage` is this session's own breakdown (`inputTokens`, `outputTokens`, +`cacheCreationTokens`, `cacheReadTokens`, `thinkingTokens`, `factoryCredits`), and +`inclusiveTokenUsage` adds child sessions, which are folded separately -- so only `tokenUsage` +is read here, never the inclusive figure. A prompt is a `user` message that is neither an +injected system reminder nor a tool result; Droid marks messages it never shows the person with +`visibility: "user_only"` or `"llm_only"`, and those are not prompts either. +""" +import json +import os +import re +from datetime import datetime + +SYSTEM_PREFIXES = (".json`), so a log's id is taken +# only when it is a plain token: Droid's are UUIDs. +SESSION_ID_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9_-]{0,127}") + + +def claude_tool_name(name): + """A Droid tool name as the equivalent Claude Code one, or unchanged.""" + return _TOOL_NAMES.get((name or "").lower(), name) + + +def droid_records(path): + """The parsed records of a Droid session, in file order (which is `seq` order).""" + out = [] + try: + with open(path, encoding="utf-8", errors="replace") as fh: + for line in fh: + line = line.strip() + if not line: + continue + try: + ev = json.loads(line) + except ValueError: + continue + if isinstance(ev, dict): + out.append(ev) + except OSError: + return [] + return out + + +def _epoch(ts): + """Droid's ISO timestamp as epoch seconds, or None.""" + if not isinstance(ts, str) or not ts: + return None + try: + return datetime.fromisoformat(ts[:-1] + "+00:00" if ts.endswith("Z") else ts).timestamp() + except (ValueError, OverflowError, OSError): + return None + + +def droid_session_header(path): + """The `session_start` record ({id, cwd, version, title, ...}), {} if absent.""" + for ev in droid_records(path): + if ev.get("type") == "session_start": + return ev + return {} + + +def droid_session_cwd(path): + """The directory the session started in, or None.""" + cwd = droid_session_header(path).get("cwd") + return cwd if isinstance(cwd, str) and cwd else None + + +def droid_log_head(path): + """A session's `{cwd, started}` from its header and first message, {} if unreadable. + + `started` is its first timestamp in epoch seconds, as every other host's head reports it.""" + head, started = {}, None + for ev in droid_records(path): + if ev.get("type") == "session_start" and not head: + head = ev + elif ev.get("type") == "message" and started is None: + started = _epoch(ev.get("timestamp")) + if head and started is not None: + break + out = {} + if isinstance(head.get("cwd"), str) and head["cwd"]: + out["cwd"] = head["cwd"] + if started: + out["started"] = started + return out + + +def droid_safe_session_id(sid): + """A log's id when it is a plain token (safe as a file name), else None.""" + return sid if isinstance(sid, str) and SESSION_ID_RE.fullmatch(sid) else None + + +def _droid_is_prompt(msg): + """A message the person (or the agent that launched a task) typed, not injected text.""" + if (msg.get("role") or "") != "user": + return False + if msg.get("visibility") in NOT_PROMPTS: + return False + content = msg.get("content") + if isinstance(content, str): + return bool(content.strip()) and not content.lstrip().startswith(SYSTEM_PREFIXES) + if not isinstance(content, list): + return False + texts = [] + for block in content: + if not isinstance(block, dict): + continue + if block.get("type") == "tool_result": + return False # a tool result returned to the model + if block.get("type") == "text": + texts.append(block.get("text") or "") + return any(t.strip() and not t.lstrip().startswith(SYSTEM_PREFIXES) for t in texts) + + +def droid_prompt_count(path): + """How many prompts the session ran.""" + return sum(1 for ev in droid_records(path) + if ev.get("type") == "message" + and _droid_is_prompt(ev.get("message") or {})) + + +def _droid_content(content): + """A message's content as blocks: strings become one text block.""" + if isinstance(content, list): + return [b for b in content if isinstance(b, dict)] + if isinstance(content, str) and content: + return [{"type": "text", "text": content}] + return [] + + +def _claude_line(ev, cwd=None): + """One Droid `message` record as the line `load_trajectory` reads, or None.""" + msg = ev.get("message") or {} + role = msg.get("role") + if role not in ("user", "assistant"): + return None + content = _droid_content(msg.get("content")) + if not content: + return None + content = [dict(b, name=claude_tool_name(b.get("name"))) + if b.get("type") == "tool_use" else b for b in content] + record = {"type": role, "message": {"role": role, "content": content}} + if msg.get("modelId"): + record["message"]["model"] = msg["modelId"] + if cwd: + record["cwd"] = cwd + return record + + +def droid_session_to_claude(path, out_path): + """Write a Droid session as Claude Code transcript lines, for `load_trajectory`. + + Every line carries the session's `cwd` (Droid records no per-call directory): a command + typed as `cd sub && make` is still read from where the session started. Returns the number + of tool calls written, or None when the file is missing.""" + if not path or not os.path.exists(path): + return None + cwd = droid_session_cwd(path) + n = 0 + with open(out_path, "w", encoding="utf-8") as out: + for ev in droid_records(path): + if ev.get("type") != "message": + continue + record = _claude_line(ev, cwd) + if record is None: + continue + n += sum(1 for b in record["message"]["content"] if b.get("type") == "tool_use") + out.write(json.dumps(record) + "\n") + return n + + +def droid_prompt_turns(path): + """Each prompt-turn of a Droid session, as Claude Code log lines from its prompt to the + next, one turn at a time. Used to seed the loop guard's envelope from imported sessions.""" + cwd = droid_session_cwd(path) + current = None + for ev in droid_records(path): + if ev.get("type") != "message": + continue + record = _claude_line(ev, cwd) + if record is None: + continue + line = json.dumps(record) + if _droid_is_prompt(ev.get("message") or {}): + if current is not None: + yield current + current = [line] + elif current is not None: + current.append(line) + if current is not None: + yield current + + +def droid_settings_path(transcript_path): + """The settings file Droid keeps beside a session log, or None.""" + if not transcript_path: + return None + base = transcript_path[:-len(".jsonl")] if transcript_path.endswith(".jsonl") else \ + transcript_path + return base + ".settings.json" + + +def droid_session_usage(transcript_path): + """Token breakdown of a Droid session from the settings file beside its log. + + Returns the four fields `pricing.effective_tokens` reads, plus `model`, `requests` (assistant + messages in the log), and Droid's own `factory_credits`. Only this session's `tokenUsage` is + read: `inclusiveTokenUsage` also counts child sessions, which are folded on their own. + Without a breakdown every count is None, so the ledger says `no_usage` rather than guessing. + """ + out = {"input_tokens": None, "cache_read_input_tokens": None, + "cache_creation_input_tokens": None, "output_tokens": None, + "requests": 0, "model": None, "factory_credits": None} + settings = droid_settings_path(transcript_path) + data = {} + if settings: + try: + with open(settings, encoding="utf-8") as fh: + data = json.load(fh) + except (OSError, ValueError): + data = {} + if not isinstance(data, dict): + data = {} + out["model"] = data.get("model") if isinstance(data.get("model"), str) else None + + models, assistant = {}, 0 + for ev in droid_records(transcript_path): + if ev.get("type") != "message": + continue + msg = ev.get("message") or {} + if msg.get("role") != "assistant": + continue + assistant += 1 + mid = msg.get("modelId") + if isinstance(mid, str) and mid: + models[mid] = models.get(mid, 0) + 1 + out["requests"] = assistant + if not out["model"] and models: + out["model"] = max(models, key=models.get) + + usage = data.get("tokenUsage") + if not isinstance(usage, dict): + return out + + def _int(key): + v = usage.get(key) + return int(v) if isinstance(v, (int, float)) and not isinstance(v, bool) else 0 + + counts = {"input_tokens": _int("inputTokens"), + "cache_read_input_tokens": _int("cacheReadTokens"), + "cache_creation_input_tokens": _int("cacheCreationTokens"), + "output_tokens": _int("outputTokens")} + if not any(counts.values()): + return out + out.update(counts) + credits = usage.get("factoryCredits") + if isinstance(credits, (int, float)) and not isinstance(credits, bool): + out["factory_credits"] = credits + return out + + +def droid_sessions(sessions_root=None, env=None): + """Every `*.jsonl` session under the Droid sessions directory, newest mtime first. + + Each row is `{path, id, cwd, version, title, started, mtime, size}`; `started` is the first + message's timestamp in epoch seconds when it has one, else the file's mtime. + `$DROID_SESSIONS_DIR` overrides + the location (tests, or a non-default install).""" + env = os.environ if env is None else env + root = sessions_root or env.get("DROID_SESSIONS_DIR") or os.path.join( + os.path.expanduser("~"), ".factory", "sessions") + if not os.path.isdir(root): + return [] + rows = [] + for dirpath, dirnames, filenames in os.walk(root): + depth = os.path.relpath(dirpath, root).count(os.sep) + if depth > 1: # `/.jsonl`, no deeper + dirnames[:] = [] + continue + for fname in filenames: + if not fname.endswith(".jsonl"): + continue + path = os.path.join(dirpath, fname) + header, started = {}, None + try: + mtime = os.path.getmtime(path) + size = os.path.getsize(path) + with open(path, encoding="utf-8", errors="replace") as fh: + for line in fh: + try: + ev = json.loads(line) + except ValueError: + continue + if ev.get("type") == "session_start" and not header: + header = ev + elif ev.get("type") == "message" and started is None: + started = _epoch(ev.get("timestamp")) + if header and started is not None: + break + except OSError: + continue + rows.append({"path": path, "id": header.get("id") or os.path.splitext(fname)[0], + "cwd": header.get("cwd") if isinstance(header.get("cwd"), str) else None, + "version": header.get("version"), "title": header.get("title"), + # A subagent session names the session that launched it; it is part + # of that session's work, not a person's session of its own. + "parent": header.get("callingSessionId"), + "started": started, "mtime": mtime, "size": size, + "folder": os.path.basename(dirpath)}) + rows.sort(key=lambda r: (r["mtime"], r["path"])) + return rows From 45a0a74ac212a9949f2dcdda4fe9b33351351aba Mon Sep 17 00:00:00 2001 From: thezelium <147515134+thezelium@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:02:43 +0100 Subject: [PATCH 02/11] droid: the side-call switch is a setting, not only a variable The Factory desktop app gives its sessions no way to set an environment variable, so `TE_DROID_SIDECALL=1` could only be used from a terminal. `droid_sidecall` says the same thing in the config file, which both surfaces read, and stays off by default: `droid exec` writes a session log of its own. --- README.md | 6 ++++++ docs/DROID.md | 11 +++++++++-- plugin/README.md | 5 +++-- tests/test_droid.py | 19 +++++++++++++++++++ tokeneconomizer/plugin/cli.py | 4 +++- tokeneconomizer/plugin/config.py | 4 ++++ tokeneconomizer/plugin/sidecall.py | 13 ++++++++----- 7 files changed, 52 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 7d65b21..35756f3 100644 --- a/README.md +++ b/README.md @@ -377,6 +377,7 @@ are these settings: | *c* | `loopguard.judge_confidence` | 0.7 | how sure the judge must be that a turn is stuck | | *r* | `loopguard_nudges` | 1 | redirects per turn before the guard only tells you | | *b* | `loopguard.backstop_mult` | 2.5 | how many times its usual length a churning turn may run before it is redirected without the judge | +| | `droid_sidecall` | off | on Droid, allow the plugin's own model calls, which run `droid exec` | Change them with `config set`, shown here in Claude Code's form (other agents type it as in [Everyday commands](#everyday-commands)): @@ -388,6 +389,11 @@ Change them with `config set`, shown here in Claude Code's form (other agents ty /tokeneconomizer:config set loopguard_nudges=2 # r = 2: redirect twice before telling you ``` +On Droid, the plugin's own calls run `droid exec`, which writes a session log of its own, so +they stay off until `config set droid_sidecall=on` (`TE_DROID_SIDECALL=1` does the same from a +terminal). With them off, `microskills=envfacts` still learns and the loop guard keeps its +rule-based backstop; only the paid fact proposer and the judge wait. + Tool pruning counts prompts so that long sessions weigh more, and requires *m* sessions so that a single session never decides alone. It writes the deny list only once your sessions in the repository have been idle for *q* minutes (or, at the latest, a day after the list formed): a diff --git a/docs/DROID.md b/docs/DROID.md index ba1af44..e4c9aea 100644 --- a/docs/DROID.md +++ b/docs/DROID.md @@ -77,10 +77,15 @@ The plugin's settings live in `~/.tokeneconomizer/config.json`: ```sh te --host droid config set microskills=envfacts # learn facts, no model call +te --host droid config set microskills=on # and let a model propose more, every k sessions +te --host droid config set droid_sidecall=on # allow the plugin's own model calls te --host droid config set loopguard=nudge # the judge stays off until side calls are on te --host droid config set toolprune=off # the only honest value on Droid ``` +`droid_sidecall` is the switch for the calls described below; `TE_DROID_SIDECALL=1` in the +environment of the session does the same, for a Droid started from a terminal. + `TE_DISABLE=1` in a session's environment turns every hook into a no-op. `te --host droid doctor` reports the Droid version, the session directory, side-call state and recent errors. @@ -89,8 +94,10 @@ reports the Droid version, the session directory, side-call state and recent err Micro-skills' paid proposer and the loop guard's judge normally call the agent's own CLI. On Droid that is `droid exec`, which writes a session log of its own under `~/.factory/sessions`. Droid has no documented flags for a pure completion (no tools, no hooks, no log), so the adapter -does not make that call unless `TE_DROID_SIDECALL=1` is set. With it off, `microskills=envfacts` -still learns, and the loop guard keeps its rule-based backstop. +does not make that call unless it is allowed, by `config set droid_sidecall=on` or by +`TE_DROID_SIDECALL=1` in the session's environment. The setting is the one that also works from +the Factory desktop app, where a session's environment cannot be chosen. With the calls off, +`microskills=envfacts` still learns, and the loop guard keeps its rule-based backstop. When it is on, the call runs at Droid's default read-only autonomy in a throwaway directory, and its cost is read from the usage Droid records for it. diff --git a/plugin/README.md b/plugin/README.md index 458be30..ab0a0b4 100644 --- a/plugin/README.md +++ b/plugin/README.md @@ -617,8 +617,9 @@ in the project's own folder: a directory that holds several repositories is lear `tokenUsage` to `.settings.json` beside its log, and the plugin prices the session from it (an unknown model is labelled `fallback`). **What does not:** tool pruning, because the TUI has no persistent per-tool setting (Factory issue #65). **Model calls** run through -`droid exec` and are off unless `TE_DROID_SIDECALL=1`: that command writes a session log of its -own, so enabling it is a deliberate choice. With it off, `microskills=envfacts` still learns. +`droid exec` and are off until `config set droid_sidecall=on` (or `TE_DROID_SIDECALL=1` in a +terminal session): that command writes a session log of its own, so enabling it is a deliberate +choice. With it off, `microskills=envfacts` still learns. **Not verified yet:** a fact reaching a live Droid model. The hook output is covered by tests; Droid appending it to the model's context is documented by Factory but has not been observed diff --git a/tests/test_droid.py b/tests/test_droid.py index d2a9ef8..30c2f02 100644 --- a/tests/test_droid.py +++ b/tests/test_droid.py @@ -160,6 +160,25 @@ def test_side_calls_are_off_unless_the_person_enables_them(tmp_path, monkeypatch assert callable(sidecall.runner_for("droid", cfg)) +def test_the_setting_turns_side_calls_on_without_a_variable(tmp_path, monkeypatch): + """The desktop app cannot set an environment variable for its hooks, so the same switch + is a setting.""" + from tokeneconomizer.plugin import sidecall + from tokeneconomizer.plugin.config import load_config, write_config + monkeypatch.delenv("TE_DROID_SIDECALL", raising=False) + write_config(str(tmp_path / "config.json"), {"droid_sidecall": "on"}) + cfg = load_config(str(tmp_path), env={}) + assert cfg.droid_sidecall is True + assert callable(sidecall.runner_for("droid", cfg)) + + +def test_the_setting_is_off_by_default_and_shown_by_config_show(): + from tokeneconomizer.plugin.cli import _config_view + from tokeneconomizer.plugin.config import LiveConfig + assert LiveConfig().droid_sidecall is False + assert _config_view(LiveConfig())["droid"] == {"state": "off", "droid_sidecall": False} + + def test_the_settings_usage_is_normalized_for_pricing(): from tokeneconomizer.plugin import sidecall usage = sidecall._droid_usage({"inputTokens": 5, "outputTokens": 7, diff --git a/tokeneconomizer/plugin/cli.py b/tokeneconomizer/plugin/cli.py index 5859655..31b8a0b 100644 --- a/tokeneconomizer/plugin/cli.py +++ b/tokeneconomizer/plugin/cli.py @@ -583,6 +583,8 @@ def _config_view(cfg): "history_max_sessions": d.pop("history_max_sessions"), "history_days": d.pop("history_days"), "history_dirs": d.pop("history_dirs")}, + "droid": {"state": "on" if cfg.droid_sidecall else "off", + "droid_sidecall": d.pop("droid_sidecall")}, "other": d, "warnings": cfg.warnings, } @@ -623,7 +625,7 @@ def cmd_config(args): print(f"{path}: {json.dumps(cur, sort_keys=True)}") print("applies from the next hook call; a new fact-size cap applies once the next session " "has been folded") - if {canonical(k) for k in updates} & {"microskills", "loopguard"} and sidecall.resume( + if {canonical(k) for k in updates} & {"microskills", "loopguard", "droid_sidecall"} and sidecall.resume( ctx.host.name, ctx.root): print(f"the plugin's model calls through `{sidecall.call_name(ctx.host.name)}` will be " f"tried again") diff --git a/tokeneconomizer/plugin/config.py b/tokeneconomizer/plugin/config.py index 3e58871..07c5a55 100644 --- a/tokeneconomizer/plugin/config.py +++ b/tokeneconomizer/plugin/config.py @@ -50,6 +50,9 @@ custom provider's, whose model they could not reach otherwise; comma-separated paths), and the `pi` they run; * hermes_cli -- on Hermes, the `hermes` the plugin's own model calls run; + * droid_sidecall -- on Droid, allow the plugin's own model calls. They run + `droid exec`, which writes a session log of its own, so this + is off by default: `microskills=envfacts` learns without it; * history_import -- on Claude Code, fold a repository's sessions from before the plugin (Claude Code's own logs) once, at its first session start, when nothing is folded there yet. Off by default: @@ -177,6 +180,7 @@ class LiveConfig: pi_extensions: list = field(default_factory=list) pi_cli: str = "" hermes_cli: str = "" + droid_sidecall: bool = False history_import: bool = False history_max_sessions: int = 200 history_days: int = 365 diff --git a/tokeneconomizer/plugin/sidecall.py b/tokeneconomizer/plugin/sidecall.py index 54214b3..c5f373c 100644 --- a/tokeneconomizer/plugin/sidecall.py +++ b/tokeneconomizer/plugin/sidecall.py @@ -749,8 +749,10 @@ def copilot_call(prompt, model="", cli="copilot", timeout=TIMEOUT_S): def droid_enabled(env=None): - """Whether Droid side calls are allowed. They are OFF by default: `droid exec` writes a - session log of its own under `~/.factory/sessions`, so a person enables them knowingly.""" + """Whether `TE_DROID_SIDECALL` allows Droid side calls. The `droid_sidecall` setting does + the same from the config file, which is the only way from the desktop app. They are OFF by + default: `droid exec` writes a session log of its own under `~/.factory/sessions`, so a + person enables them knowingly.""" env = os.environ if env is None else env return str(env.get("TE_DROID_SIDECALL") or "").strip().lower() in ("1", "true", "yes", "on") @@ -887,9 +889,10 @@ def _host_call(host_name, config): if host_name == "copilot": return lambda prompt, _m="agent": copilot_call(prompt, model=model) if host_name == "droid": - # Off unless the person turned it on: see `droid_enabled`. No runner means the free - # layer still learns; only the paid proposer and judge wait. - if not droid_enabled(): + # Off unless the person turned it on, by the setting or the variable: see + # `droid_enabled`. No runner means the free layer still learns; only the paid proposer + # and the judge wait. + if not (droid_enabled() or getattr(config, "droid_sidecall", False)): return None return lambda prompt, _m="agent": droid_call(prompt, model=model) return lambda prompt, _m="agent": claude_call(prompt, model=model or "haiku") From 352d5ee3cf776c899241781db332258cf0e29f59 Mon Sep 17 00:00:00 2001 From: thezelium <147515134+thezelium@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:03:24 +0100 Subject: [PATCH 03/11] droid: ship only the hook file Droid reads, and no macOS metadata --- scripts/build_plugin.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/scripts/build_plugin.py b/scripts/build_plugin.py index db6488c..8087c57 100644 --- a/scripts/build_plugin.py +++ b/scripts/build_plugin.py @@ -28,7 +28,7 @@ import sys REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -IGNORE = shutil.ignore_patterns("__pycache__", "*.pyc", ".pytest_cache") +IGNORE = shutil.ignore_patterns("__pycache__", "*.pyc", ".pytest_cache", ".DS_Store") def build(out): @@ -80,6 +80,11 @@ def build_droid(out): # such event). shutil.copyfile(os.path.join(REPO, "plugin", "hooks", "droid-hooks.json"), os.path.join(plugin_out, "hooks", "hooks.json")) + for name in ("droid-hooks.json", "codex-hooks.json", "copilot-hooks.json", + "cursor-hooks.json"): # other agents' hook files: Droid reads one + stale = os.path.join(plugin_out, "hooks", name) + if os.path.exists(stale): + os.remove(stale) # Slash commands become Droid commands. Droid has an Execute tool, not Claude's Bash, and # expands ${DROID_PLUGIN_ROOT}. commands_out = os.path.join(plugin_out, "commands") From e45a53fc012aa402720898ba0c353e905ed196b3 Mon Sep 17 00:00:00 2001 From: thezelium <147515134+thezelium@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:08:40 +0100 Subject: [PATCH 04/11] doctor: say how Droid's side calls are switched on, from the settings in effect --- tokeneconomizer/plugin/cli.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tokeneconomizer/plugin/cli.py b/tokeneconomizer/plugin/cli.py index 31b8a0b..21997e6 100644 --- a/tokeneconomizer/plugin/cli.py +++ b/tokeneconomizer/plugin/cli.py @@ -822,6 +822,7 @@ def cmd_doctor(args): "agy" if h == "antigravity" else h)] print("agents " + (', '.join(agents) or 'none of antigravity (agy), claude, codex, ' 'copilot, droid, grok, hermes, opencode, pi on PATH')) + ctx = _ctx(args) # the per-agent lines below read the settings in effect for h in agents: pause = sidecall.paused(h, root) if pause: @@ -858,8 +859,12 @@ def cmd_doctor(args): if "droid" in agents: sessions = os.environ.get("DROID_SESSIONS_DIR") or os.path.join( os.path.expanduser("~"), ".factory", "sessions") - side = ("on (TE_DROID_SIDECALL=1)" if sidecall.droid_enabled() - else "off (set TE_DROID_SIDECALL=1 to try them)") + if getattr(ctx.config, "droid_sidecall", False): + side = "on (the `droid_sidecall` setting)" + elif sidecall.droid_enabled(): + side = "on (TE_DROID_SIDECALL=1)" + else: + side = ("off (set `droid_sidecall=on` or TE_DROID_SIDECALL=1 to try them)") print(f"droid sessions {sessions}; side calls {side}; tool pruning is not " f"available in the TUI") if "hermes" in agents: @@ -875,7 +880,6 @@ def cmd_doctor(args): pkgs = [] mine = [p for p in pkgs if "tokeneconomizer" in json.dumps(p).lower()] print(f"pi package {'installed: ' + str(mine[0]) if mine else 'not in ' + settings}") - ctx = _ctx(args) print(f"repository {ctx.repo.root}: {repos.describe(ctx.repo)}; state {ctx.repo.key}") home_git = os.path.join(paths.home_dir(), ".git") for path in repos.ignored_git_entries(args.cwd): From 8979bf8098b0b3dfc1283ed5621d471026dd8a25 Mon Sep 17 00:00:00 2001 From: thezelium <147515134+thezelium@users.noreply.github.com> Date: Thu, 5 Nov 2026 13:09:53 +0100 Subject: [PATCH 05/11] droid: record the live verification of the injected block A new Droid 0.233.0 session received the 793-character block as `hookSpecificOutput.additionalContext` and the model repeated it verbatim. Update the four places that still called this hop unverified. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- README.md | 9 +++++---- docs/DROID.md | 15 +++++++++------ docs/PLUGIN.md | 8 +++++--- plugin/README.md | 7 ++++--- 4 files changed, 23 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 35756f3..03930aa 100644 --- a/README.md +++ b/README.md @@ -319,10 +319,11 @@ have not been tested yet. [its limits](plugin/README.md#hermes-agent). - **Factory Droid** support is experimental: it was tested on 0.233.0 only, on macOS. Droid documents the hook contract this adapter uses, and the plugin's own hook output is covered by - tests, but a learned fact reaching a live Droid model has not yet been verified. Droid's TUI - has no persistent per-tool setting (Factory issue #65), so tool pruning is not available; its - session logs are read for micro-skills and the loop guard, and priced from the usage Droid - writes beside each log. See [its notes](docs/DROID.md). + tests; a learned fact reaching a live Droid model was verified on 2026-11-05 (a new session + received the 793-character block as `hookSpecificOutput.additionalContext` and the model + repeated it). Droid's TUI has no persistent per-tool setting (Factory issue #65), so tool + pruning is not available; its session logs are read for micro-skills and the loop guard, and + priced from the usage Droid writes beside each log. See [its notes](docs/DROID.md). ### Everyday commands diff --git a/docs/DROID.md b/docs/DROID.md index e4c9aea..6977e61 100644 --- a/docs/DROID.md +++ b/docs/DROID.md @@ -110,9 +110,11 @@ its cost is read from the usage Droid records for it. - Documented by Factory and relied on here: `SessionStart` appends `hookSpecificOutput.additionalContext`; `UserPromptSubmit` and `PostToolUse` accept `additionalContext`; there is no `systemMessage` and no `PostToolUseFailure`. -- **Not verified**: a learned fact reaching a live Droid model. The hook writes the right JSON, - but the last hop (Droid appending it to the model's context) has not been observed end to end. - Treat this as the open item for Droid support. +- **Verified live on 2026-11-05**, on Droid 0.233.0: a new session (`bb18c4fc`) received the + 793-character block as `hookSpecificOutput.additionalContext`, and the model repeated it + verbatim. The hook's JSON, Droid appending it to the model's context, and the model repeating + it have now all been observed end to end. The block was three facts at the time; a fourth was + added by the next fold. Droid's session and hook formats are internal. The adapter fails open: any error answers `{}` and writes one line to `~/.tokeneconomizer/errors.log`, so a Droid update can at worst stop the plugin @@ -132,6 +134,7 @@ Deleting that directory forgets everything it learned. ## Compatibility -Tested on Droid `0.233.0` (macOS). After a Droid update, run one session and check -`te --host droid doctor` and `~/.tokeneconomizer/errors.log`. If Droid changes the session log -shape, `te --host droid history import` will import nothing, which is the safe failure. +Tested on Droid `0.233.0` (macOS), including the live injection of a learned block on +2026-11-05. After a Droid update, run one session and check `te --host droid doctor` and +`~/.tokeneconomizer/errors.log`. If Droid changes the session log shape, +`te --host droid history import` will import nothing, which is the safe failure. diff --git a/docs/PLUGIN.md b/docs/PLUGIN.md index 434d2e4..5290771 100644 --- a/docs/PLUGIN.md +++ b/docs/PLUGIN.md @@ -708,9 +708,11 @@ was not.) the text as on Claude Code (Droid reports no exit code field that the adapter relies on). * **Tool pruning** is not available: the TUI has no persistent per-tool setting, and Factory issue #65 asks for one (`droid exec --only-tools` is exec-only). -* **Not verified:** a fact written by the hook reaching a live Droid model. The hook output is - covered, the last hop is not. Also unverified: whether `droid exec` can be restricted to a - pure completion, which is why side calls are off unless `TE_DROID_SIDECALL=1`. +* **Verified live on 2026-11-05:** a fact written by the hook reached a live Droid model. A new + session (`bb18c4fc`) received the 793-character block as + `hookSpecificOutput.additionalContext`, and the model repeated it verbatim. The hook output + and the last hop are both observed. Still unverified: whether `droid exec` can be restricted + to a pure completion, which is why side calls are off unless `TE_DROID_SIDECALL=1`. ## 4. State and privacy diff --git a/plugin/README.md b/plugin/README.md index ab0a0b4..135f4a6 100644 --- a/plugin/README.md +++ b/plugin/README.md @@ -621,9 +621,10 @@ TUI has no persistent per-tool setting (Factory issue #65). **Model calls** run terminal session): that command writes a session log of its own, so enabling it is a deliberate choice. With it off, `microskills=envfacts` still learns. -**Not verified yet:** a fact reaching a live Droid model. The hook output is covered by tests; -Droid appending it to the model's context is documented by Factory but has not been observed -here. +**Verified live on 2026-11-05:** a fact reaching a live Droid model. A new session +(`bb18c4fc`) received the 793-character block as `hookSpecificOutput.additionalContext`, and the +model repeated it verbatim. The hook output is covered by tests; Droid appending it to the +model's context has now been observed. ## What to expect From f475c33fddf8311300403a75e10e5c5c9a28ff44 Mon Sep 17 00:00:00 2001 From: thezelium <147515134+thezelium@users.noreply.github.com> Date: Fri, 6 Nov 2026 14:16:56 +0100 Subject: [PATCH 06/11] droid: drop the note that the adapter is not in an upstream release docs/DROID.md said the adapter "is not part of an upstream release yet", which stops being true once it is merged. The experimental status, the tested version and the platform stay in the same paragraph. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- docs/DROID.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/DROID.md b/docs/DROID.md index 6977e61..5dcd494 100644 --- a/docs/DROID.md +++ b/docs/DROID.md @@ -1,7 +1,7 @@ # TokenEconomizer on Factory Droid Droid support is experimental. It was built and tested against Droid `0.233.0` on macOS; no -other version has been tested. It is not part of an upstream release yet. +other version has been tested. ## What works, and what does not From 10e8769447fe164d6d22dbda6b39343bd682388e Mon Sep 17 00:00:00 2001 From: thezelium <147515134+thezelium@users.noreply.github.com> Date: Sat, 7 Nov 2026 12:46:23 +0100 Subject: [PATCH 07/11] droid: name Droid in the lists Grok was added to Grok, merged into main while this branch was open, added itself to six lists that never named Droid: the hosts in docs/PLUGIN.md's opening, the side-call commands in its privacy section and its per-agent limitations, the run table in plugin/README.md, the `--host` values in hook.py's docstring, and the module table in trace/README.md. Droid is now in each, with what the branch already says about it. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- docs/PLUGIN.md | 8 ++++++-- plugin/README.md | 1 + tokeneconomizer/plugin/hook.py | 4 ++-- tokeneconomizer/trace/README.md | 1 + 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/docs/PLUGIN.md b/docs/PLUGIN.md index 5290771..0d5b2f2 100644 --- a/docs/PLUGIN.md +++ b/docs/PLUGIN.md @@ -1,7 +1,7 @@ # The plugin: design and verified agent behaviour How the plugin runs the three mechanisms inside your own Claude Code, Codex, Copilot CLI, Pi, -opencode, Cursor, Grok Build, Hermes Agent and Antigravity CLI sessions, through the hooks (on Pi, the extension events) each agent +opencode, Cursor, Grok Build, Hermes Agent, Antigravity CLI and Factory Droid sessions, through the hooks (on Pi, the extension events) each agent exposes: what it does at each hook, what differs from the experiments and why, the agent behaviour each choice rests on, where its state lives, and its known limitations. The code is `tokeneconomizer/plugin/` (what the hooks run) and `plugin/` (the packaging for each agent). To install and use it, see @@ -772,7 +772,8 @@ by default with the guard), of micro-skills (its proposer, when it is on), and, it, of `toolprune measure` on Claude Code. Those calls send **digests** — command templates, edited file basenames, error lines with paths and numbers masked, never file contents — through your own agent CLI (`claude -p` / `codex exec` / -`copilot -p` / `pi -p` / `opencode run` / `grok -p` / `hermes chat`), billed where your session is. The proposer keeps the digests of the last eight +`copilot -p` / `pi -p` / `opencode run` / `grok -p` / `hermes chat` / `droid exec`, the last only with +`droid_sidecall` on), billed where your session is. The proposer keeps the digests of the last eight sessions with its state, raw commands and output capped, so that each run sees more than the session that just ended. Sessions imported from Claude Code's history call no model, but their digests join that window, so the proposer's next call can include digests of sessions from @@ -846,6 +847,9 @@ open-vocabulary. On a repository you do not trust, leave micro-skills off (the d agy versions (unknown, not zero), and the transcript names no model, so session cost is priced on the ledger's labelled fallback. macOS and Linux only: agy runs hook commands through `cmd /c` on Windows, and the launcher is a POSIX shell script. +* **Factory Droid (experimental)** — micro-skills, the loop guard and session cost, on 0.233.0 + (macOS). No tool pruning: the TUI has no persistent per-tool setting (Factory issue #65). Side + calls are off until `droid_sidecall` is on; see `docs/DROID.md`. * **Known gaps:** * `codex exec` prints no hook messages, so a headless Codex run shows no notice; `te status` counts it afterwards. On Claude Code the notice reaches the conversation and `claude -p`'s diff --git a/plugin/README.md b/plugin/README.md index 135f4a6..ee9c328 100644 --- a/plugin/README.md +++ b/plugin/README.md @@ -711,6 +711,7 @@ How to run them: | Grok Build | none (run from a terminal) | `~/.tokeneconomizer/bin/te --host grok ` | | Hermes Agent | none (run from a terminal) | `~/.tokeneconomizer/bin/te --host hermes ` | | Antigravity CLI | none (run from a terminal) | `~/.tokeneconomizer/bin/te --host antigravity ` | +| Factory Droid | `/tokeneconomizer-` | `~/.tokeneconomizer/bin/te --host droid ` | `doctor`, `history` and `repomap` run from a terminal (or as `/te …` on Pi); there is no slash command or Codex skill for them. diff --git a/tokeneconomizer/plugin/hook.py b/tokeneconomizer/plugin/hook.py index 67bd1ae..52ab161 100644 --- a/tokeneconomizer/plugin/hook.py +++ b/tokeneconomizer/plugin/hook.py @@ -1,7 +1,7 @@ """The entry point every plugin hook executes: hook JSON on stdin, hook JSON on stdout. - python -m tokeneconomizer.plugin.hook [--host antigravity|claude|codex|copilot|cursor|grok|hermes| - opencode|pi] [--event NAME] + python -m tokeneconomizer.plugin.hook [--host antigravity|claude|codex|copilot|cursor|droid|grok| + hermes|opencode|pi] [--event NAME] It never fails the agent. Whatever goes wrong -- unreadable input, a full disk, a bug -- the hook prints `{}` (which changes nothing) and records the traceback in `/errors.log`. It diff --git a/tokeneconomizer/trace/README.md b/tokeneconomizer/trace/README.md index b335c69..adc6f14 100644 --- a/tokeneconomizer/trace/README.md +++ b/tokeneconomizer/trace/README.md @@ -13,6 +13,7 @@ session as an ordered list of `(tool_name, args, result_text, success)` calls th | `codex.py` | `codex exec --json` streams; a session's rollout file, converted to that stream; usage | | `copilot.py` | Copilot CLI event logs: calls, usage, prompt-turns, MCP servers | | `cursor.py` | Cursor Agent hook events, normalized to a Claude-shaped trajectory; usage is unavailable | +| `droid.py` | Factory Droid sessions: one JSONL file per session, its message records unwrapped into a Claude transcript; prompts; usage from the `.settings.json` beside it | | `grok.py` | Grok Build sessions: `updates.jsonl` (ACP updates) written as a Claude transcript; prompts and usage from its session files | | `hermes.py` | Hermes Agent sessions: a copy of a session's `state.db` rows without the conversation's words, written as a Claude transcript; usage | | `pi.py` | Pi's session tree: the active branch, written as a Claude transcript; usage | From 7ad320eb337912a88a4e96eb8c7edfc7d288c091 Mon Sep 17 00:00:00 2001 From: thezelium <147515134+thezelium@users.noreply.github.com> Date: Sat, 7 Nov 2026 12:47:10 +0100 Subject: [PATCH 08/11] droid: start the side call through program, as the other hosts do `droid_call` ran `[cli, "exec", ...]`, so on Windows an npm-installed droid would be a `.cmd` shim that cmd.exe parses again, prompt included. It now starts through `program(cli)`, like every other side call, and its throwaway directory ignores cleanup errors as theirs do. `test_every_side_call_starts_its_cli_through_program` now covers droid_call (it failed before this change), and the autouse `_no_model_calls` fixture stubs droid_call with the other calls. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- tests/conftest.py | 4 ++-- tests/test_live_core.py | 2 +- tokeneconomizer/plugin/sidecall.py | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index fe7119e..7f7978d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -57,6 +57,6 @@ def _no_model_calls(request, monkeypatch): monkeypatch.setattr(judge, "spawn_job", judge.run_job) if request.node.get_closest_marker("real_sidecalls"): return - for name in ("claude_call", "codex_call", "copilot_call", "grok_call", "hermes_call", - "pi_call"): + for name in ("claude_call", "codex_call", "copilot_call", "droid_call", "grok_call", + "hermes_call", "pi_call"): monkeypatch.setattr(sidecall, name, lambda *a, **k: ("", None)) diff --git a/tests/test_live_core.py b/tests/test_live_core.py index 4e05d61..f43fd5c 100644 --- a/tests/test_live_core.py +++ b/tests/test_live_core.py @@ -1088,7 +1088,7 @@ def fake_run(cmd, **kw): prompt = 'Say "hi & exit\n100% of %PATH%' for call, cli in ((sidecall.codex_call, "codex"), (sidecall.copilot_call, "copilot"), (sidecall.opencode_call, "opencode"), (sidecall.pi_call, "pi"), - (sidecall.cursor_call, "cursor-agent")): + (sidecall.cursor_call, "cursor-agent"), (sidecall.droid_call, "droid")): seen.clear() call(prompt) assert seen and all(c[:2] == ["/n/node", f"/n/{cli}.js"] for c in seen), seen diff --git a/tokeneconomizer/plugin/sidecall.py b/tokeneconomizer/plugin/sidecall.py index c5f373c..4b434ab 100644 --- a/tokeneconomizer/plugin/sidecall.py +++ b/tokeneconomizer/plugin/sidecall.py @@ -807,13 +807,13 @@ def droid_call(prompt, model="", cli="droid", timeout=TIMEOUT_S): no-ops inside the nested session, so a side call cannot recurse; the default read-only autonomy means it cannot write either. Its own usage is read from the `exec` result or from the settings file beside the session it wrote, so the call is charged rather than guessed.""" - cmd = [cli, "exec", "--output-format", "json", "--disable-builtin-skills", + cmd = [*program(cli), "exec", "--output-format", "json", "--disable-builtin-skills", "--append-system-prompt", "Answer with text only. Do not call any tool. Your entire reply is the answer."] if model: cmd += ["--model", model] cmd.append(prompt) - with tempfile.TemporaryDirectory(prefix="te-side-") as cwd: + with tempfile.TemporaryDirectory(prefix="te-side-", ignore_cleanup_errors=True) as cwd: out = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout, env=_env(), cwd=cwd, stdin=subprocess.DEVNULL) From 50688566bcb11d24ce8954986234f3293652e130 Mon Sep 17 00:00:00 2001 From: thezelium <147515134+thezelium@users.noreply.github.com> Date: Sat, 7 Nov 2026 12:50:02 +0100 Subject: [PATCH 09/11] droid: restrict the side call's tools with --only-tools The side call relied on its system prompt ("Do not call any tool") and on Droid's default read-only autonomy, which on 0.233.0 still allows Execute, the web tools and every MCP tool (`droid exec --list-tools`). It now passes `--only-tools Read`, Droid's own allowlist. The list cannot be empty -- `--only-tools ""` reads as no filter and an unknown name is refused -- so it keeps the one read-only tool, as Grok's `--tools read_file` does. With the call's own flags, `--list-tools` reports Read as the only tool allowed, MCP tools included. A live side call asked to run shell commands, write a file and search the web called no tool (its session log holds no tool_use) and wrote nothing. Droid has no setting for a call with no tool at all, and `droid exec` writes a session log of its own, so side calls stay off by default (`droid_sidecall`, or TE_DROID_SIDECALL=1). docs/DROID.md says so in "Side calls are off by default"; docs/PLUGIN.md, README.md and plugin/README.md say the same, and test_droid.py pins the flag. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- README.md | 8 ++++--- docs/DROID.md | 19 ++++++++++----- docs/PLUGIN.md | 10 ++++++-- plugin/README.md | 3 ++- tests/test_droid.py | 30 +++++++++++++++++++++++ tokeneconomizer/plugin/sidecall.py | 38 ++++++++++++++++++++---------- 6 files changed, 83 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 03930aa..967ee41 100644 --- a/README.md +++ b/README.md @@ -181,7 +181,8 @@ Start a new Droid session (its hooks are snapshotted at startup) and run `/tokeneconomizer-status`. Droid keeps each session's token usage in a settings file beside its log, so folded sessions are priced like any other host's. Its TUI has no persistent per-tool setting, so tool pruning stays off; and because `droid exec` writes a session log of its own, -micro-skills' paid proposer and the loop guard's judge stay off unless `TE_DROID_SIDECALL=1`. +micro-skills' paid proposer and the loop guard's judge stay off until `droid_sidecall` is on +(when on, they may use only Droid's read-only `Read` tool). See **[`docs/DROID.md`](docs/DROID.md)** for what works and how to import earlier sessions. **Cursor Agent** (experimental) @@ -392,8 +393,9 @@ Change them with `config set`, shown here in Claude Code's form (other agents ty On Droid, the plugin's own calls run `droid exec`, which writes a session log of its own, so they stay off until `config set droid_sidecall=on` (`TE_DROID_SIDECALL=1` does the same from a -terminal). With them off, `microskills=envfacts` still learns and the loop guard keeps its -rule-based backstop; only the paid fact proposer and the judge wait. +terminal). When on, they run with `--only-tools Read`, so `Read` is the only tool they may use. +With them off, `microskills=envfacts` still learns and the loop guard keeps its rule-based +backstop; only the paid fact proposer and the judge wait. Tool pruning counts prompts so that long sessions weigh more, and requires *m* sessions so that a single session never decides alone. It writes the deny list only once your sessions in the diff --git a/docs/DROID.md b/docs/DROID.md index 5dcd494..47172ca 100644 --- a/docs/DROID.md +++ b/docs/DROID.md @@ -92,15 +92,18 @@ reports the Droid version, the session directory, side-call state and recent err ## Side calls are off by default Micro-skills' paid proposer and the loop guard's judge normally call the agent's own CLI. On -Droid that is `droid exec`, which writes a session log of its own under `~/.factory/sessions`. -Droid has no documented flags for a pure completion (no tools, no hooks, no log), so the adapter -does not make that call unless it is allowed, by `config set droid_sidecall=on` or by -`TE_DROID_SIDECALL=1` in the session's environment. The setting is the one that also works from +Droid that is `droid exec`, which writes a session log of its own under `~/.factory/sessions`, +and Droid has no setting that leaves such a call with no tool at all. So the adapter does not make +that call unless it is allowed, by `config set droid_sidecall=on` or by `TE_DROID_SIDECALL=1` in +the session's environment. The setting is the one that also works from the Factory desktop app, where a session's environment cannot be chosen. With the calls off, `microskills=envfacts` still learns, and the loop guard keeps its rule-based backstop. -When it is on, the call runs at Droid's default read-only autonomy in a throwaway directory, and -its cost is read from the usage Droid records for it. +When it is on, the call runs in a throwaway directory at Droid's default read-only autonomy, with +its tools limited by a Droid setting rather than only by the prompt: `--only-tools Read` leaves +`Read` as the one tool it may use. That is as close to no tools as Droid allows (`--only-tools ""` +means no filter), and it matters: the default read-only autonomy alone still allows `Execute`, +the web tools and every MCP tool. Its cost is read from the usage Droid records for the call. ## What is verified, and what is not @@ -115,6 +118,10 @@ its cost is read from the usage Droid records for it. verbatim. The hook's JSON, Droid appending it to the model's context, and the model repeating it have now all been observed end to end. The block was three facts at the time; a fourth was added by the next fold. +- Verified on Droid 0.233.0: with the side call's own flags, `droid exec --list-tools` reports + `Read` as the only tool allowed, MCP tools included, and a live side call on 2026-11-07, + asked to run shell commands, write a file and search the web, called no tool and wrote + nothing. `tests/test_droid.py` pins the flag. Droid's session and hook formats are internal. The adapter fails open: any error answers `{}` and writes one line to `~/.tokeneconomizer/errors.log`, so a Droid update can at worst stop the plugin diff --git a/docs/PLUGIN.md b/docs/PLUGIN.md index 0d5b2f2..8a8659f 100644 --- a/docs/PLUGIN.md +++ b/docs/PLUGIN.md @@ -711,8 +711,14 @@ was not.) * **Verified live on 2026-11-05:** a fact written by the hook reached a live Droid model. A new session (`bb18c4fc`) received the 793-character block as `hookSpecificOutput.additionalContext`, and the model repeated it verbatim. The hook output - and the last hop are both observed. Still unverified: whether `droid exec` can be restricted - to a pure completion, which is why side calls are off unless `TE_DROID_SIDECALL=1`. + and the last hop are both observed. +* **Side call.** `droid exec` cannot run with no tool at all (`--only-tools ""` reads as no + filter), so the call passes `--only-tools Read`. On 0.233.0, with the call's own flags, + `--list-tools` reports `Read` as the only tool allowed (without it, the default read-only + autonomy also allows `Execute`, the web tools and every MCP tool), and a live call asked to + run shell commands, write a file and search the web called no tool and wrote nothing. The + call also writes a session log of its own, so side calls stay off until `droid_sidecall` is + on (or `TE_DROID_SIDECALL=1`). ## 4. State and privacy diff --git a/plugin/README.md b/plugin/README.md index ee9c328..35bce31 100644 --- a/plugin/README.md +++ b/plugin/README.md @@ -619,7 +619,8 @@ from it (an unknown model is labelled `fallback`). **What does not:** tool pruni TUI has no persistent per-tool setting (Factory issue #65). **Model calls** run through `droid exec` and are off until `config set droid_sidecall=on` (or `TE_DROID_SIDECALL=1` in a terminal session): that command writes a session log of its own, so enabling it is a deliberate -choice. With it off, `microskills=envfacts` still learns. +choice. When on, they run with `--only-tools Read`, so `Read` is the only tool they may use; +Droid has no setting for none at all. With it off, `microskills=envfacts` still learns. **Verified live on 2026-11-05:** a fact reaching a live Droid model. A new session (`bb18c4fc`) received the 793-character block as `hookSpecificOutput.additionalContext`, and the diff --git a/tests/test_droid.py b/tests/test_droid.py index 30c2f02..f089f1c 100644 --- a/tests/test_droid.py +++ b/tests/test_droid.py @@ -1,8 +1,11 @@ """Factory Droid: the session reader, its host adapter, usage, and history import.""" import json import os +import subprocess import time +import pytest + from tokeneconomizer import trace from tokeneconomizer.plugin import accounting, denylist, history, hosts from tokeneconomizer.plugin.context import LiveContext @@ -179,6 +182,33 @@ def test_the_setting_is_off_by_default_and_shown_by_config_show(): assert _config_view(LiveConfig())["droid"] == {"state": "off", "droid_sidecall": False} +@pytest.mark.real_sidecalls +def test_a_droid_side_call_allows_only_the_read_tool(monkeypatch): + """Droid's default read-only autonomy still allows its shell, the web tools and every MCP + tool, so the side call names the one tool it may use instead of asking the model not to.""" + from tokeneconomizer.plugin import sidecall + monkeypatch.setattr(sidecall, "program", lambda cli: [cli]) + seen = {} + + def fake_run(cmd, **kw): + seen.update(cmd=cmd, **kw) + out = json.dumps({"type": "result", "result": "- use python -m pytest", + "session_id": "s1", + "usage": {"input_tokens": 900, "output_tokens": 40}}) + return subprocess.CompletedProcess(cmd, 0, stdout=out, stderr="") + + monkeypatch.setattr(sidecall.subprocess, "run", fake_run) + text, eff = sidecall.droid_call("propose facts", model="claude-sonnet-5") + assert text == "- use python -m pytest" and eff and eff > 0 + cmd = seen["cmd"] + assert cmd[:2] == ["droid", "exec"] and cmd[-1] == "propose facts" + assert cmd[cmd.index("--only-tools") + 1] == sidecall.DROID_TOOLS == "Read" + assert "--auto" not in cmd and "--skip-permissions-unsafe" not in cmd + assert cmd[cmd.index("--model") + 1] == "claude-sonnet-5" + assert seen["env"]["TE_SIDECALL"] == "1" and seen["stdin"] == subprocess.DEVNULL + assert seen["cwd"] != os.getcwd() # a throwaway directory + + def test_the_settings_usage_is_normalized_for_pricing(): from tokeneconomizer.plugin import sidecall usage = sidecall._droid_usage({"inputTokens": 5, "outputTokens": 7, diff --git a/tokeneconomizer/plugin/sidecall.py b/tokeneconomizer/plugin/sidecall.py index 4b434ab..0b87f89 100644 --- a/tokeneconomizer/plugin/sidecall.py +++ b/tokeneconomizer/plugin/sidecall.py @@ -44,11 +44,12 @@ the person's default model. Never `hermes -z`, which auto-approves dangerous commands: a side call's prompt holds text a repository controls, and this form offers it no tool. See `hermes_call` for why `TE_SIDECALL` is not how it avoids the plugin's hooks. - * Factory Droid: `droid exec --output-format json --disable-builtin-skills`, in a throwaway - directory at Droid's default read-only autonomy, on the person's default model. OFF unless - `TE_DROID_SIDECALL=1`: Droid writes a session log of its own for the call, and has no - documented flags for a pure completion (no tools, no hooks, no log). Its usage is read from - the call's result or from the settings file beside that log. + * Factory Droid: `droid exec --output-format json --disable-builtin-skills --only-tools Read`, + in a throwaway directory at Droid's default read-only autonomy, on the person's default + model, with only the read-only `Read` tool allowed. OFF unless `droid_sidecall` is on (or + `TE_DROID_SIDECALL=1`): Droid writes a session log of its own for the call, and cannot be + left with no tool at all. Its usage is read from the call's result or from the settings file + beside that log. Every side call but Hermes's runs with `TE_SIDECALL=1`, which the plugin's hook entry point treats as "do nothing": without it the nested agent would run this plugin's hooks and recurse. A call that @@ -799,16 +800,27 @@ def _droid_settings_usage(sid): return None -def droid_call(prompt, model="", cli="droid", timeout=TIMEOUT_S): - """A text-only completion through `droid exec`, in a throwaway directory. +# A Droid side call only answers, and its prompt quotes commands the session ran, so it must not +# be able to act. Droid's default read-only autonomy still lists `Execute`, the web tools and every +# MCP tool as allowed (0.233.0, `droid exec --list-tools`), so the call names its tools with +# `--only-tools`, an allowlist. It cannot be empty -- `--only-tools ""` reads as no filter, and a +# name droid does not know is refused -- so it keeps the one read-only tool, as Grok's does. +# Verified on 0.233.0: with the call's own flags, `--list-tools` reports `Read` as the only +# tool allowed, MCP tools included. +DROID_TOOLS = "Read" + - Droid has no documented flags for a pure completion (no tools, no hooks, no session log), - so this is off unless `TE_DROID_SIDECALL=1`. `TE_SIDECALL=1` makes this plugin's own hooks - no-ops inside the nested session, so a side call cannot recurse; the default read-only - autonomy means it cannot write either. Its own usage is read from the `exec` result or from - the settings file beside the session it wrote, so the call is charged rather than guessed.""" +def droid_call(prompt, model="", cli="droid", timeout=TIMEOUT_S): + """A text-only completion through `droid exec`, in a throwaway directory, with only the + read-only `Read` tool allowed (`--only-tools`, see `DROID_TOOLS`). + + Droid cannot be left with no tool at all, and `droid exec` writes a session log of its own, + so this is off unless the person turns it on (`droid_enabled`, or the `droid_sidecall` + setting). `TE_SIDECALL=1` makes this plugin's own hooks no-ops inside the nested session, so + a side call cannot recurse. Its own usage is read from the `exec` result or from the + settings file beside the session it wrote, so the call is charged rather than guessed.""" cmd = [*program(cli), "exec", "--output-format", "json", "--disable-builtin-skills", - "--append-system-prompt", + "--only-tools", DROID_TOOLS, "--append-system-prompt", "Answer with text only. Do not call any tool. Your entire reply is the answer."] if model: cmd += ["--model", model] From 5f838c6951c63825e1c8444e52611cc277b6d2b4 Mon Sep 17 00:00:00 2001 From: thezelium <147515134+thezelium@users.noreply.github.com> Date: Mon, 9 Nov 2026 09:25:35 +0100 Subject: [PATCH 10/11] droid: keep Antigravity's files out of the Droid bundle `build_droid` copies `plugin/` and ships only what Droid loads. Antigravity's root `hooks.json` and `.antigravity-plugin/` sit beside the Droid layout now, and Droid reads neither: its hooks are `hooks/hooks.json`, which the bundle writes from `droid-hooks.json`. Leaving them out keeps the bundle to the files Droid reads; the packaging test says so. --- scripts/build_plugin.py | 1 + tests/test_droid.py | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/scripts/build_plugin.py b/scripts/build_plugin.py index 8087c57..1cecfac 100644 --- a/scripts/build_plugin.py +++ b/scripts/build_plugin.py @@ -72,6 +72,7 @@ def build_droid(out): ignore=shutil.ignore_patterns( "__pycache__", "*.pyc", ".pytest_cache", "lib", ".DS_Store", ".claude-plugin", ".codex-plugin", ".cursor-plugin", ".github", + ".antigravity-plugin", "hooks.json", "codex-skills", "hermes", "opencode", "pi", "slash-commands", "package.json", "*.md.bak")) shutil.copytree(os.path.join(REPO, "tokeneconomizer"), diff --git a/tests/test_droid.py b/tests/test_droid.py index f089f1c..b30aef7 100644 --- a/tests/test_droid.py +++ b/tests/test_droid.py @@ -321,5 +321,9 @@ def test_droid_plugin_packaging(tmp_path): command = (plugin / "commands" / "tokeneconomizer-status.md").read_text() assert "${DROID_PLUGIN_ROOT}" in command and "Execute tool" in command assert (plugin / "lib" / "tokeneconomizer" / "plugin" / "hook.py").exists() + # Antigravity's files, which sit at the plugin root beside Droid's: Droid reads neither (its + # hooks are `hooks/hooks.json`), and the bundle ships only what Droid loads. + assert not (plugin / "hooks.json").exists() + assert not (plugin / ".antigravity-plugin").exists() assert json.load(open(out / ".factory-plugin" / "marketplace.json"))["plugins"][0][ "name"] == "tokeneconomizer" From d4d387b56192307298f3abdf5b8a88e0b53a3592 Mon Sep 17 00:00:00 2001 From: Saksham Uboweja <76595406+SakshamUboweja@users.noreply.github.com> Date: Wed, 11 Nov 2026 16:25:53 -0800 Subject: [PATCH 11/11] Add droid_paid_model to configuration --- tokeneconomizer/plugin/config.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tokeneconomizer/plugin/config.py b/tokeneconomizer/plugin/config.py index 80f399f..d95eb08 100644 --- a/tokeneconomizer/plugin/config.py +++ b/tokeneconomizer/plugin/config.py @@ -48,7 +48,7 @@ * repomap, repomap_tokens, repomap_python, repomap_timeout -- the opt-in repository map; * _paid_model -- the model of the plugin's own calls on that agent, over `paid_model` (claude, codex, copilot, pi, opencode, cursor, - grok, hermes); + grok, hermes, droid); * claude_paid_effort, codex_paid_effort -- their reasoning effort on those two agents; * pi_extensions, pi_cli -- on Pi, the extensions the plugin's own model calls load (a custom provider's, whose model they could not reach otherwise; @@ -189,6 +189,7 @@ class LiveConfig: cursor_paid_model: str = "" grok_paid_model: str = "" hermes_paid_model: str = "" + droid_paid_model: str = "" claude_paid_effort: str = "" codex_paid_effort: str = "" pi_extensions: list = field(default_factory=list) @@ -304,7 +305,7 @@ def _set_threshold(self, name, value, source): _ENV = {"TE_MODE": "mode", "TE_SHADOW": "shadow", "TE_PAID_MODEL": "paid_model", "TE_VOCABULARY": "vocabulary", **{f"TE_{a.upper()}_PAID_MODEL": f"{a}_paid_model" for a in ( - "claude", "codex", "copilot", "pi", "opencode", "cursor", "grok", "hermes")}, + "claude", "codex", "copilot", "pi", "opencode", "cursor", "grok", "hermes", "droid")}, "TE_CLAUDE_PAID_EFFORT": "claude_paid_effort", "TE_CODEX_PAID_EFFORT": "codex_paid_effort", "TE_INJECT": "microskills", "TE_INJECT_MAX_FACTS": "microskills_max_facts", "TE_INJECT_MAX_TOKENS": "microskills_max_tokens",