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..967ee41 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,24 @@ 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 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) ```sh @@ -249,6 +267,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 +289,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 +318,13 @@ 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; 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 @@ -314,13 +341,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 | @@ -351,6 +379,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)): @@ -362,6 +391,12 @@ 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). 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 repository have been idle for *q* minutes (or, at the latest, a day after the list formed): a @@ -404,6 +439,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 +475,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 +499,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..47172ca --- /dev/null +++ b/docs/DROID.md @@ -0,0 +1,147 @@ +# 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. + +## 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 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. + +## 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`, +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 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 + +- 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`. +- **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. +- 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 +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), 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 8de0510..9faf383 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 @@ -686,6 +686,40 @@ 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). +* **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. +* **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 All state is local, under `$TOKENECONOMIZER_HOME` (default `~/.tokeneconomizer`), one directory @@ -748,7 +782,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 @@ -822,6 +857,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/.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 1483d7f..9f3459e 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,48 @@ 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 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. 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 +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 The plugin works session by session: what it learns from a repository's earlier sessions is @@ -669,6 +712,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/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..1cecfac 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 @@ -27,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): @@ -54,14 +55,84 @@ 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", + ".antigravity-plugin", "hooks.json", + "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")) + 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") + 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/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_droid.py b/tests/test_droid.py new file mode 100644 index 0000000..b30aef7 --- /dev/null +++ b/tests/test_droid.py @@ -0,0 +1,329 @@ +"""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 +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_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} + + +@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, + "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() + # 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" diff --git a/tests/test_live_core.py b/tests/test_live_core.py index 24c9648..7e52ca7 100644 --- a/tests/test_live_core.py +++ b/tests/test_live_core.py @@ -281,7 +281,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" @@ -1176,7 +1177,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/tests/test_live_hooks.py b/tests/test_live_hooks.py index 36dfa05..97028a7 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 5838624..5d00c72 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"] @@ -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") @@ -654,7 +656,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 +665,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 +815,14 @@ 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')) + ctx = _ctx(args) # the per-agent lines below read the settings in effect for h in agents: pause = sidecall.paused(h, root) if pause: @@ -853,6 +856,17 @@ 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") + 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: 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}" @@ -866,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): diff --git a/tokeneconomizer/plugin/config.py b/tokeneconomizer/plugin/config.py index c71538b..d95eb08 100644 --- a/tokeneconomizer/plugin/config.py +++ b/tokeneconomizer/plugin/config.py @@ -48,12 +48,15 @@ * 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; 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: @@ -186,11 +189,13 @@ 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) pi_cli: str = "" hermes_cli: str = "" + droid_sidecall: bool = False history_import: bool = False history_max_sessions: int = 200 history_days: int = 365 @@ -300,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", 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/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/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 dc617a2..73a6811 100644 --- a/tokeneconomizer/plugin/sidecall.py +++ b/tokeneconomizer/plugin/sidecall.py @@ -44,6 +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 --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 @@ -745,9 +751,120 @@ 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 `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") + + +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 + + +# 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" + + +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", + "--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] + cmd.append(prompt) + 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) + 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): @@ -788,4 +905,11 @@ def _host_call(host_name, config): return lambda prompt, _m="agent": codex_call(prompt, model=model, **kw) 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, 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", **kw) 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 | 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