Skip to content

HT001 · Writing a terminal IDE from scratch

On 2026-09-07, an agent under flower wrote, from nothing, a single-file C/C++ IDE that runs in the macOS terminal. It ran for 10.4 hours straight, spent $171.62, and delivered 12,212 lines of product code and 13,291 lines of tests. The network dropped once in the middle; it picked itself up and finished. This page is not a showcase — it is every claim the framework makes, measured once at real scale, including the two that did not survive: "the coordinator frequently ignores permission limits" turned out to be false, and one item in its own self-acceptance was judged wrong.

The full record (including a 19MB transcript) is at ChenyuHeee/cppide. Every number below is computed by tools/analyze_run.py from sessions.db, and is reproducible:

python tools/analyze_run.py /path/to/cppide/runs

Where the numbers on this page come from

HT001's runs/ lives in that cppide repo, not in the flower repo. So the numbers on this page were carried over verbatim from the run record at the time; while writing this page nobody recomputed them against sessions.db again — to recompute, you would first have to clone cppide and run the command above. HT002 is the opposite: the raw record is in this repo, every number on that page was recomputed item by item, and the two that did not match are written directly onto the page.

The shape of this run

Item Value
Task "Build a single-file C/C++ IDE in the macOS terminal, for ICPC practice"
Cost $171.62 — clarify $0.3704 / 5 turns, work $171.2476 / 31 turns
Duration 0.06h + 10.44h
Main thread context 28.7K → 185.9K, monotonically increasing over 70 model turns, never compacted
Division of labor coordinator 32 tool calls vs subagent 1,893; 94.8% of body characters landed in subagents
Cache 299.4M input tokens total, 96.1% hit
Output src/ 12,212 lines, tests/ 13,291 lines (~3,000 assertions), README 44K
Version flower before the goal guard was added, so this run had no per-turn verdict

This page has two different kinds of 'turn'

The 5 turns / 31 turns in the table are the num_turns recorded in runs/manifest.json, one count per step; "turn 70" in the context curve below counts model turns on the main thread, i.e. every assistant message. They count different things — do not convert between them.


1. Context economics: 94.8% of the legwork never entered the main thread

This is the framework's core claim — the coordinator holds only decisions, the legwork sinks into subagents and disk. For the mechanism itself see context economics.

Main thread subagent subagent share
Model turns 70 3.0K 97.7%
Body characters 200.1K 3.6M 94.8%
Tool calls 32 1,893 ——

This run dispatched 23 subagents; output tokens: max 184,377, median 72,197, min 11,684.

Of the 1,893 hands-on tool calls (1,034 Bash / 471 Read / 258 Edit / 126 Write), only 32 entered the coordinator's view — 59:1. On average, for each dispatch there are 82 tool calls it never sees.

An earlier small-scale test measured 83%; at real scale it is 94.8%. The larger the run, the greater the payoff from the split — because the task brief and the reply back cost a fixed amount per dispatch, while the legwork kept outside grows with task complexity.

2. The context curve: the first time we know where the "long-horizon" ceiling is

For each assistant message, input + cache_read + cache_creation is the context the model saw on that turn:

turn  1   28.7K   ← startup floor (previously measured at about 34k — matches)
turn 20   35.2K
turn 35  108.6K
turn 50  158.2K
turn 70  185.9K   ← end
  • Monotonic growth over 70 turns, slope about 2.2K/turn
  • No compaction occurred at any point (DISABLE_AUTO_COMPACT=1; the single dip is the empty record from the network-drop turn)
  • Used 18.6% of the 1M window

Extrapolating at that slope, the wall is at about turn 440. These 70 turns produced 12K lines of code, so the real long-horizon ceiling in the current form is about this run. That is a number you can make decisions with; before, it could only be guessed.

To break past it, main-thread growth has to become sublinear — more sunk to disk, less kept in the conversation.

3. 96.1% cache hit — $171.62 only works because of this

total input 299.4M tokens
  cache hit    287.5M  (96.1%)
  cache write   11.8M
  uncached       6.2K
output 2.3M (of which thinking 653.9K)

299M input tokens cost only $171, because 96% went at cache prices.

Corollary: the economics of a long-horizon run rest on the cache hit rate, and the cache hit rate depends on the stability of the context prefix. Any "rearrange the context" optimization — moving old content out, inserting a new system prompt near the front — punches through the cache, and the tokens saved may be worth far less than the cache discount lost. This had never been measured before; now there is a number.

It is also a side reason flower uses handoff rather than compaction: a handoff opens a new session and builds the cache from scratch, so the prefix stays stable within a generation.

4. Workbench reuse: 61 scripts, not one written and thrown away

The workbench claim is "write a script once, then just run it, never rewrite it." The way to verify it is not to ask the agent, but to count how many times the scripts in .flower/scripts/ were Written/Edited and Bash-executed in the transcript:

61 scripts: written 95 times, executed 331 times
executed more than once (genuinely reused): 56, i.e. 92%
written but never executed: 0
execution/write ratio 3.48

wave5-verify.sh was written 5 times and run 45 times — polished through iteration, then used over and over, exactly the intended shape. Not one script was written and discarded.

5. The coordinator was denied once, and it was the allowlist's fault

The suspicion going in was: "the coordinator frequently ignores permission limits and triggers a flood of denied calls." Measurement knocked that premise out.

1 denial out of 32 tool calls (3.1%), and that one was:

which g++ clang++ make pkg-config 2>&1; echo ---; ls /usr/include/ncurses.h 2>&1; echo ---; ls /work/runs

Segment by segment: echo allowed, ls allowed, which blocked — it was not in the ephemeral command table.

The whole command is purely read-only, a standard environment probe. The model was entirely correct; the allowlist was incomplete, forcing it to pay a subagent startup cost (about 4.3k tokens) for one which g++.

Fixed: added which / command -v / type / whereis / uname / arch / locale / nproc / getconf / sw_vers. command and type are allowed only in query form — a bare command rm -rf / is execution, so omitting that -v would be a back door; 7 adversarial samples in tests/glance.py guard it.

The thing worth remembering here is not the patch, it's the method. A long-running architecture debate whose starting premise measures 1/32 on real data, with the attribution backwards — the problem was not the model, it was the allowlist. Measure first.

6. Resuming after a network drop: verified by a real failure for the first time

01:52:40  tool_result: "Agent terminated early due to an API error"   ← a subagent was interrupted
01:55:41  assistant:   "API Error: ENOTFOUND"                          ← received by the main thread
          ↓  the probe hangs, waiting for the network to come back
          resumed=True, continued in the same session, ran 8+ more hours to completion

The manifest records attempts=2 / resumed=True / ok=True. Ten hours of work did not restart from zero. Before this, resilience sat under "still unverified" — killing the network needs sudo-editing hosts, and nobody wants to do that during a real run.

That synthetic "API Error" message was stripped by PruningSessionStore at load time, so the model never saw it — the prune design was verified by the same real failure.

Two problems it exposed (see issue #2):

  1. Recovery happens at the framework layer, not the transport layer. The cost is replaying the whole context, and that context was large.
  2. The interrupted subagent's half-finished work was lost. Resume saves the main thread; subagent-level interruption recovery is a separate problem.

7. Was its self-verification real?

This is the part that deserves the most suspicion: an agent writing its own tests, running them itself, and declaring a pass can easily become theater.

Afterwards an independent audit subagent was dispatched to statically read all 58 scripts and 22 deliverables. The conclusion is yes, and unusually strict:

  • Zero tautological assertions. Nowhere in the repo is there anything like [ 1 = 1 ].
  • 8 uses of || true, 7 of them legitimate (grep -c returning 1 on no match, ulimit fallbacks, and so on).
  • It asserts behavior, not file existence. audit-idle-cost.sh:53 lets the IDE idle for 60 seconds inside a pty, counts the requests actually received by a local fake server, and PASSes only at -le 2. fix-p1-idle-verify.sh:100-110 goes further: it asserts both that idling fires exactly 1 request and that the ghost still displays — guarding against "the rate-limiting change killed the feature too."
  • It really spawns child processes. wave5-pty-drive.cpp:316 uses forkpty to actually run the TUI; audit-fake-ai-server.py is a real socket server supporting five fault injections, /err500 /err401 /badjson /drop /slow; all AI paths force-clear CPPIDE_API_KEY.

Two things exceeded expectations.

It built positive and negative controls for its own verifier. 验收审计.md:29-34: first feed ZZZ_NEVER_APPEARS_ZZZ to confirm the driver FAILs, then feed the real string to confirm it PASSes — avoiding "auditing with a machine that always PASSes." That is verifying the verifier itself.

It disclosed its own weak assertion, unprompted. The for m in $must in wave5-b4-ai-states.sh:114 splits "AI 错误 请求超时" on whitespace into three substrings and greps each separately, and AI and 错误 are bound to appear on any screen — nominally "3 checks passed", but only 1 actually discriminates. It wrote this itself into 验收审计.md:576-582; the auditor did not dig it out.

Similar cases: wave5-verify.sh:296-302 spends 7 lines of comments explaining why B3b was downgraded to SKIP (a final-screen timing assertion, unrelated to product code), noting that "the verdict logic and tolerance ranges were not changed by a single character"; :236-238 records that it found and fixed a fake assertion it had written earlierif pgrep ... silently falls to the else branch in a slim image without pgrep, becoming an always-PASS.

The qualitative evidence for reuse matches the numbers in section 4: audit-fake-ai-server.py is reused by 7 scripts; wave5-pty-drive.cpp is reused across 5+ builds and caches compilation with [ -x "$DRIVE" ] ||; the wave4 → wave5 driver is an extension, not a rewrite (the diff changes 1 line and adds 343, keeping wave4's 281 lines verbatim).

All of this happened on a version without the goal guard. The only constraint was the 10 decidable acceptance criteria in the brief. Which means: writing "done" in decidable form is by itself enough to induce self-audit and self-disclosure. The marginal value of the goal guard has to be measured against that baseline, not against zero.

But the next section shows: the self-audit is technically strong at verification, and yet got the final call wrong on the item that mattered most.

The 'auditor' here is not the judge

The independent auditor in sections 7 and 8 is a subagent the coordinator dispatched on its own initiative, not the judge of the goal guard — this run had no goal guard at all. They read alike but do different things: the auditor is a post-hoc review, the judge is a verdict gate at the end of every turn.

8. Was its self-scoring right — acceptance criterion 1 was judged wrong

A second independent auditor was dispatched to judge those 10 acceptance criteria one by one against the code.

Criteria 2–10 all hold, with code evidence for each. Practice mode injecting no code is even a structural guarantee: a single sink decision point at ai.h:117 + a single landing switch at app.cpp:590-615 + four early-returns at editor.cpp:679-683, not scattered ifs. The P1–P4 issues it found in self-audit all hold, and all fixes landed (P4 half-holds: that blue pair line is dead code, ghostAttr() already goes through A_DIM).

But acceptance criterion 1 was judged wrong.

criterion text:  "Compiles to a standalone executable that runs directly in the macOS terminal"
delivered artifact: ELF 64-bit LSB pie executable, ARM aarch64, GNU/Linux
its verdict:     pass

This one was verified by hand, running file.

It did not hide anything — the title of §4 of 交付说明.md is literally "Biggest unverified risk: macOS was never run once", and §6 of the audit lists six unverified macOS surfaces. It disclosed the risk, but never connected that fact back to the verdict on criterion 1, and criterion 1's literal requirement is precisely that thing.

But this is not evidence that "you need an independent judge"

It is easy to read this as "so you need the goal guard." Wrong, and reading it that way is painting the target around the arrow — exactly the kind of motivated reasoning criticized in the previous section.

The goal guard as it existed then would, in its default configuration, most likely have missed this too. judge() defaults to can_run=False, with only Read / Glob / Grep as tools — it cannot run file. It would read the source, see that Makefile:25-38 does have a Darwin branch, and rule "achieved." The exact same failure as the self-audit.

The real structural fact

Acceptance criterion 1 was simply unverifiable in this run's environment.

What it asserts is "the artifact's behavior on macOS", and the entire run happened inside a Linux container. No judge can verify a macOS binary from inside that container, self-audit or independent. The problem is not "who judges", it is that this item was treated as an ordinary decidable criterion and then judged.

Three more the auditor dug up (none mentioned in the self-audit)

  • doTab()'s quota semantics: app.cpp:909 unconditionally calls noteActivity() after acceptGhost()gen_++ → reopens the quota, so every Tab acceptance fires another automatic request 500ms later. The P1 analysis (idle burn) only audited the three cancellation points setMode / cancelInFlight / askNow, and missed this, the highest-frequency money-spending path.
  • The "gray" in criterion 6 only holds literally on 256-color terminals; the 8-color tier is hard-coded to A_DIM (dim, not gray).
  • The §7 subheading in 验收审计.md says "9 acceptance criteria" while the table has 10 rows — a typo, but in the summary table header.

9. Final scale of spill and workbench

103 spills, 791.4K characters turned into path pointers, never resident in context.

.flower/ at the end: 58 scripts (250K), 22 deliverables (330K), 5 notes (43K).

Residual risk

The delivery target is macOS, but all verification happened inside a Debian aarch64 container; zero verification on real macOS. That is the real side effect of the container isolation approach: to be safe you lock it into Linux, and what it must deliver is macOS. The project's own README §10.24 honestly records this and lists 4 risk points, but does not close the loop.

That gap was later closed by HT002 — that run is precisely about installing this binary onto real macOS.

An unexpected benefit: inside the container only /work is visible, so across the 19MB transcript the host username /Users/hechenyu leaks zero times (/work appears 11,495 times). Isolation isolated the paths as a side effect.

What this run could not verify

  • Goal guard: this run predates it. The coordinator organized an independent audit on its own, and the verification technique is solid, but it got the most important item wrong (section 8). Both conclusions must be recorded together — writing decidable criteria into the brief is enough to induce self-audit, so the goal guard's baseline is not zero; and the goal guard in its default configuration at the time would have missed that item too, so it is not a cure-all either.
  • Behavior at the 1M window: only 18.6% was used, the boundary was never touched.
  • Micro-compaction behavior under DISABLE_AUTO_COMPACT=1: compaction never triggered, so this remains inference.

What this run changed in the framework

Finding Change that landed
which blocked (section 5) is_ephemeral() extended with which / command -v / type / whereis / uname / arch / locale / nproc / getconf / sw_vers; 7 adversarial samples added to tests/glance.py to hold the query-only form of command / type
Acceptance criterion 1 judged wrong (section 8) JUDGE_RULES item 1 changed to judge the artifact, not the source — run it if you can; if you can't, still inspect the artifact itself (platform, size, whether it loads)
"I disclosed the risk" bought a pass The verdict must distinguish "not done" from "cannot be verified here". Verdict.parse now recognizes 无法验证 / 没法验证 / 无法判定 as a third state, unreachablesurface it to a human, never allow a pass
Criterion 1 was unverifiable in principle in this environment The goal-setting step now requires flagging, on the spot, the items this environment cannot verify — don't discover it after the work is done
Network recovery lives at the framework layer, subagent work-in-progress lost (section 6) Recorded as issue #2, not closed

The first three hit a real API for the first time in HT002, where "judge the artifact, not the source" saved a case on the spot — but the same rule also produced a new failure.