A commit through the MCP needs the whole file in one call (tea_file_change, tea_commit), and a model's one answer carries about 100 KB at most. ventures/havre/app.js on teahubxyz/time is 174 KB, so a change of eight small hunks to it could not be committed today: three attempts hit the output limit mid-call. The same will happen to any long file in the factory.
Fix: a tea_file_edit tool. It reads the file at the branch, applies exact-text replacements in order (each old must occur exactly once in the file as it stands; new takes its place, empty removes it), and commits the result as one update with the file's sha, under the caller's own authority like every other tool. It answers with the new file's size and sha256, so the caller can check the result against their own copy before building on it. Same bounds as tea_commit (3 MB), no deletes.
Route: mcp/tea_mcp/server.py (the tool, version 2.3.0), mcp/tests/test_server.py (two tests: replacements in order and what it answers; every refusal before anything is committed), mcp/README.md. Tests run from a clean checkout on hb-5 (this sandbox can't reach PyPI), then PR, merge, boring_teahub_deploy of the merged main SHA, health check, and a decision line in docs/decisions.md.
DONE: tea_file_edit is on prod (mcp.teahub.xyz says version 2.3.0 and lists the tool), the unit tests pass on the merged SHA, and ventures/havre/app.js on havre-next has been edited with it to sha256 12023d935a811658 (174102 bytes), checked from the Mac.
A commit through the MCP needs the whole file in one call (`tea_file_change`, `tea_commit`), and a model's one answer carries about 100 KB at most. `ventures/havre/app.js` on `teahubxyz/time` is 174 KB, so a change of eight small hunks to it could not be committed today: three attempts hit the output limit mid-call. The same will happen to any long file in the factory.
Fix: a `tea_file_edit` tool. It reads the file at the branch, applies exact-text replacements in order (each `old` must occur exactly once in the file as it stands; `new` takes its place, empty removes it), and commits the result as one update with the file's sha, under the caller's own authority like every other tool. It answers with the new file's size and sha256, so the caller can check the result against their own copy before building on it. Same bounds as `tea_commit` (3 MB), no deletes.
Route: `mcp/tea_mcp/server.py` (the tool, version 2.3.0), `mcp/tests/test_server.py` (two tests: replacements in order and what it answers; every refusal before anything is committed), `mcp/README.md`. Tests run from a clean checkout on hb-5 (this sandbox can't reach PyPI), then PR, merge, `boring_teahub_deploy` of the merged main SHA, health check, and a decision line in `docs/decisions.md`.
DONE: `tea_file_edit` is on prod (mcp.teahub.xyz says version 2.3.0 and lists the tool), the unit tests pass on the merged SHA, and `ventures/havre/app.js` on `havre-next` has been edited with it to sha256 `12023d935a811658` (174102 bytes), checked from the Mac.
Branch tea-file-edit: fcd70f294df6 (server.py, README.md), 060f3a3154ac (tests). PR #13 is open; the session that wrote it was not allowed to merge its own pull request, so the merge waits for the owner.
Tests: 40 of 40 from a clean checkout of the branch on hb-5 (Python 3.13.16, requirements.txt + pydantic + starlette in a venv); the checkout went to the Trash. This Claude sandbox cannot reach PyPI, so they could not run there.
Hashes checked from the Mac (sha256, first 16): mcp/tea_mcp/server.py c48dbba4668d4a7e, mcp/tests/test_server.py 667420bc15ba5f22, mcp/README.md 05fdff2bf5d273e2.
The job this is for, to run once the tool is on prod: tea_file_edit on teahubxyz/time, branch havre-next, path ventures/havre/app.js, with the ten edits below (derived from the branch file, sha256 7ee643b866c72a29, 172097 bytes, and checked to rebuild the new file exactly). The answer's sha256 must start 12023d935a811658 and its size must be 174102. Message: "havre: what venice saw under a refused photo or clip" (+ the session's attribution lines).
[{"old":" refused: [] };\n","new":" refused: [], detail: null };\n"},{"old":" if (a.status) box.append(h(\"p\", { class: \"status\" + (a.bad ? \" bad\" : \"\"), role: \"status\", text: a.status }));\n syncAsk();\n","new":" if (a.status) box.append(h(\"p\", { class: \"status\" + (a.bad ? \" bad\" : \"\"), role: \"status\", text: a.status }));\n if (a.status && a.bad && a.detail) box.append(sawBlock(a.detail));\n syncAsk();\n"},{"old":" Object.assign(a, { status: \"writing the shot…\", bad: false });\n","new":" Object.assign(a, { status: \"writing the shot…\", bad: false, detail: null });\n"},{"old":"}\nasync function takePhoto() {\n","new":"}\n/** what was sent to Venice and what it answered, for the block under a failure (venice.js sent); null when it never got that far */\nconst saw = (e, m) => (e && e.sent ? { ...e.sent, name: (m && m.name) || e.sent.model, status: e.status, code: e.code || \"\", said: e.said || \"\", body: e.body || \"\" } : null);\n/**\n * Under a photo or a clip that didn't come: the model and the call, Venice's answer as it came, the words in the prompt\n * a content filter may weigh (abilities.js filterWords), and the prompt itself, to read or copy. Venice's stock line\n * (\"violates the content policy of Venice.ai or the model provider\") never says which words, so this is how to find them.\n */\nfunction sawBlock(d) {\n const words = A.filterWords(d.prompt), n = d.pictures || 0;\n const call = [d.name === d.model ? d.model : `${d.name} (${d.model})`, d.endpoint, n ? `${n} picture${n === 1 ? \"\" : \"s\"}` : \"\",\n d.safe_mode == null ? \"\" : `safe mode ${d.safe_mode ? \"on\" : \"off\"}`].filter(Boolean).join(\" · \");\n const answer = d.said || (d.body && d.body !== \"{}\" ? d.body : \"\") || \"nothing more\";\n const text = [call, `venice: ${d.status || \"no answer\"}${d.code ? \" \" + d.code : \"\"} — ${answer}`,\n `words a filter may weigh (ages, youth): ${words.length ? words.map((w) => `“${w}”`).join(\", \") : \"none havre knows of\"}. a real person's name, or a public figure's, is turned down too.`,\n \"prompt:\", String(d.prompt || \"\")].join(\"\\n\");\n const copy = h(\"button\", { type: \"button\", class: \"btn small\", text: \"COPY\" });\n copy.addEventListener(\"click\", () => copyFrom(\"ask-saw\", copy));\n return h(\"details\", { class: \"saw\" }, h(\"summary\", { text: \"what venice saw\" }), h(\"pre\", { class: \"saw\", id: \"ask-saw\", text }), h(\"div\", { class: \"acts\" }, copy));\n}\nasync function takePhoto() {\n"},{"old":" Object.assign(a, { status: `taking the photo with ${pm.name}…`, bad: false });\n","new":" Object.assign(a, { status: `taking the photo with ${pm.name}…`, bad: false, detail: null });\n"},{"old":" if (e.refused && !a.refused.includes(pm.id)) a.refused.push(pm.id); // the next model is a click away (askAlt)\n Object.assign(a, { status: trouble(e, c), bad: true });\n renderAsk();\n","new":" if (e.refused && !a.refused.includes(pm.id)) a.refused.push(pm.id); // the next model is a click away (askAlt)\n Object.assign(a, { status: trouble(e, c), bad: true, detail: saw(e, pm) }); // and what Venice saw is a click away (sawBlock)\n renderAsk();\n"},{"old":" Object.assign(a, { status: c.look.description ? `drawing ${c.name} with ${pm.name}…` : \"imagining how they look…\", bad: false });\n","new":" Object.assign(a, { status: c.look.description ? `drawing ${c.name} with ${pm.name}…` : \"imagining how they look…\", bad: false, detail: null });\n"},{"old":" Object.assign(a, { status: redraw(e), bad: true });\n","new":" Object.assign(a, { status: redraw(e), bad: true, detail: saw(e, pm) });\n"},{"old":" Object.assign(a, { status: \"asking venice to start the clip…\", bad: false });\n","new":" Object.assign(a, { status: \"asking venice to start the clip…\", bad: false, detail: null });\n"},{"old":" a.busy = false;\n Object.assign(a, { status: trouble(e, c), bad: true });\n renderAsk();\n","new":" a.busy = false;\n Object.assign(a, { status: trouble(e, c), bad: true, detail: saw(e, cm) });\n renderAsk();\n"}]
The six other Havre files of that change are already on havre-next (abilities.js, venice.js, style.css, skill.md, tests/abilities.test.mjs, tests/smoke.mjs); app.js references abilities.js filterWords, so refresh must wait for it.
Receipt so far (2026-10-16, late):
- Branch `tea-file-edit`: `fcd70f294df6` (server.py, README.md), `060f3a3154ac` (tests). PR #13 is open; the session that wrote it was not allowed to merge its own pull request, so the merge waits for the owner.
- Tests: 40 of 40 from a clean checkout of the branch on hb-5 (Python 3.13.16, `requirements.txt` + pydantic + starlette in a venv); the checkout went to the Trash. This Claude sandbox cannot reach PyPI, so they could not run there.
- Hashes checked from the Mac (sha256, first 16): `mcp/tea_mcp/server.py` c48dbba4668d4a7e, `mcp/tests/test_server.py` 667420bc15ba5f22, `mcp/README.md` 05fdff2bf5d273e2.
The job this is for, to run once the tool is on prod: `tea_file_edit` on `teahubxyz/time`, branch `havre-next`, path `ventures/havre/app.js`, with the ten edits below (derived from the branch file, sha256 7ee643b866c72a29, 172097 bytes, and checked to rebuild the new file exactly). The answer's sha256 must start `12023d935a811658` and its size must be 174102. Message: "havre: what venice saw under a refused photo or clip" (+ the session's attribution lines).
```json
[{"old": " refused: [] };\n", "new": " refused: [], detail: null };\n"}, {"old": " if (a.status) box.append(h(\"p\", { class: \"status\" + (a.bad ? \" bad\" : \"\"), role: \"status\", text: a.status }));\n syncAsk();\n", "new": " if (a.status) box.append(h(\"p\", { class: \"status\" + (a.bad ? \" bad\" : \"\"), role: \"status\", text: a.status }));\n if (a.status && a.bad && a.detail) box.append(sawBlock(a.detail));\n syncAsk();\n"}, {"old": " Object.assign(a, { status: \"writing the shot…\", bad: false });\n", "new": " Object.assign(a, { status: \"writing the shot…\", bad: false, detail: null });\n"}, {"old": "}\nasync function takePhoto() {\n", "new": "}\n/** what was sent to Venice and what it answered, for the block under a failure (venice.js sent); null when it never got that far */\nconst saw = (e, m) => (e && e.sent ? { ...e.sent, name: (m && m.name) || e.sent.model, status: e.status, code: e.code || \"\", said: e.said || \"\", body: e.body || \"\" } : null);\n/**\n * Under a photo or a clip that didn't come: the model and the call, Venice's answer as it came, the words in the prompt\n * a content filter may weigh (abilities.js filterWords), and the prompt itself, to read or copy. Venice's stock line\n * (\"violates the content policy of Venice.ai or the model provider\") never says which words, so this is how to find them.\n */\nfunction sawBlock(d) {\n const words = A.filterWords(d.prompt), n = d.pictures || 0;\n const call = [d.name === d.model ? d.model : `${d.name} (${d.model})`, d.endpoint, n ? `${n} picture${n === 1 ? \"\" : \"s\"}` : \"\",\n d.safe_mode == null ? \"\" : `safe mode ${d.safe_mode ? \"on\" : \"off\"}`].filter(Boolean).join(\" · \");\n const answer = d.said || (d.body && d.body !== \"{}\" ? d.body : \"\") || \"nothing more\";\n const text = [call, `venice: ${d.status || \"no answer\"}${d.code ? \" \" + d.code : \"\"} — ${answer}`,\n `words a filter may weigh (ages, youth): ${words.length ? words.map((w) => `“${w}”`).join(\", \") : \"none havre knows of\"}. a real person's name, or a public figure's, is turned down too.`,\n \"prompt:\", String(d.prompt || \"\")].join(\"\\n\");\n const copy = h(\"button\", { type: \"button\", class: \"btn small\", text: \"COPY\" });\n copy.addEventListener(\"click\", () => copyFrom(\"ask-saw\", copy));\n return h(\"details\", { class: \"saw\" }, h(\"summary\", { text: \"what venice saw\" }), h(\"pre\", { class: \"saw\", id: \"ask-saw\", text }), h(\"div\", { class: \"acts\" }, copy));\n}\nasync function takePhoto() {\n"}, {"old": " Object.assign(a, { status: `taking the photo with ${pm.name}…`, bad: false });\n", "new": " Object.assign(a, { status: `taking the photo with ${pm.name}…`, bad: false, detail: null });\n"}, {"old": " if (e.refused && !a.refused.includes(pm.id)) a.refused.push(pm.id); // the next model is a click away (askAlt)\n Object.assign(a, { status: trouble(e, c), bad: true });\n renderAsk();\n", "new": " if (e.refused && !a.refused.includes(pm.id)) a.refused.push(pm.id); // the next model is a click away (askAlt)\n Object.assign(a, { status: trouble(e, c), bad: true, detail: saw(e, pm) }); // and what Venice saw is a click away (sawBlock)\n renderAsk();\n"}, {"old": " Object.assign(a, { status: c.look.description ? `drawing ${c.name} with ${pm.name}…` : \"imagining how they look…\", bad: false });\n", "new": " Object.assign(a, { status: c.look.description ? `drawing ${c.name} with ${pm.name}…` : \"imagining how they look…\", bad: false, detail: null });\n"}, {"old": " Object.assign(a, { status: redraw(e), bad: true });\n", "new": " Object.assign(a, { status: redraw(e), bad: true, detail: saw(e, pm) });\n"}, {"old": " Object.assign(a, { status: \"asking venice to start the clip…\", bad: false });\n", "new": " Object.assign(a, { status: \"asking venice to start the clip…\", bad: false, detail: null });\n"}, {"old": " a.busy = false;\n Object.assign(a, { status: trouble(e, c), bad: true });\n renderAsk();\n", "new": " a.busy = false;\n Object.assign(a, { status: trouble(e, c), bad: true, detail: saw(e, cm) });\n renderAsk();\n"}]
```
The six other Havre files of that change are already on `havre-next` (abilities.js, venice.js, style.css, skill.md, tests/abilities.test.mjs, tests/smoke.mjs); app.js references `abilities.js filterWords`, so refresh must wait for it.
Done (2026-10-16, 23:35Z). tea_file_edit on prod applied the ten edits above to teahubxyz/timehavre-nextventures/havre/app.js in one call: commit 4f75ea4546b56c4636f3789066998649648e3ce7, answer size 174102, sha256 12023d935a81165880596f4651ea68583f4fd4d673c8ad7c47499783334d29fb — matches the expected 12023d935a811658; re-hashed from the Mac off the raw commit URL: 12023d935a811658, 174102 bytes.
Published: refresh run 462 (slug havre, from havre-next), success, 23:30:53Z → 23:33:55Z.
Live hashes (sha256, first 16, from the Mac, cache-busted): app.js 12023d935a811658 · abilities.js dc735d1441ae0d44 · venice.js 3b1696120a578130 · skill.md 17fe5e2a4aec9493 · style.css 48e34ce649cbdd0b · index.html fa5718b23bcdaabd · card.js 4a9d3618963487e9 — all as expected.
Boot check (headless Chrome on hb-5, temp folder trashed): dom bytes 30574 · ids 147 of 147 missing: [] · empty tiles 8 · studio pane-list True · chat-back ← characters.
Ledger: docs/decisions.md entry for MCP 2.3.0 / tea_file_edit, commit a801cccd25a6 (also made with tea_file_edit, on the file's last 300 characters). The DONE line is met: the live app.js hash matches. Closing.
Done (2026-10-16, 23:35Z). `tea_file_edit` on prod applied the ten edits above to `teahubxyz/time` `havre-next` `ventures/havre/app.js` in one call: commit `4f75ea4546b56c4636f3789066998649648e3ce7`, answer size 174102, sha256 `12023d935a81165880596f4651ea68583f4fd4d673c8ad7c47499783334d29fb` — matches the expected `12023d935a811658`; re-hashed from the Mac off the raw commit URL: 12023d935a811658, 174102 bytes.
Published: refresh run [462](https://teahub.xyz/teahubxyz/time/actions/runs/462) (slug havre, from havre-next), success, 23:30:53Z → 23:33:55Z.
Live hashes (sha256, first 16, from the Mac, cache-busted): app.js `12023d935a811658` · abilities.js `dc735d1441ae0d44` · venice.js `3b1696120a578130` · skill.md `17fe5e2a4aec9493` · style.css `48e34ce649cbdd0b` · index.html `fa5718b23bcdaabd` · card.js `4a9d3618963487e9` — all as expected.
Boot check (headless Chrome on hb-5, temp folder trashed): `dom bytes 30574 · ids 147 of 147 missing: [] · empty tiles 8 · studio pane-list True · chat-back ← characters`.
Ledger: `docs/decisions.md` entry for MCP 2.3.0 / `tea_file_edit`, commit `a801cccd25a6` (also made with `tea_file_edit`, on the file's last 300 characters). The DONE line is met: the live app.js hash matches. Closing.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
A commit through the MCP needs the whole file in one call (
tea_file_change,tea_commit), and a model's one answer carries about 100 KB at most.ventures/havre/app.jsonteahubxyz/timeis 174 KB, so a change of eight small hunks to it could not be committed today: three attempts hit the output limit mid-call. The same will happen to any long file in the factory.Fix: a
tea_file_edittool. It reads the file at the branch, applies exact-text replacements in order (eacholdmust occur exactly once in the file as it stands;newtakes its place, empty removes it), and commits the result as one update with the file's sha, under the caller's own authority like every other tool. It answers with the new file's size and sha256, so the caller can check the result against their own copy before building on it. Same bounds astea_commit(3 MB), no deletes.Route:
mcp/tea_mcp/server.py(the tool, version 2.3.0),mcp/tests/test_server.py(two tests: replacements in order and what it answers; every refusal before anything is committed),mcp/README.md. Tests run from a clean checkout on hb-5 (this sandbox can't reach PyPI), then PR, merge,boring_teahub_deployof the merged main SHA, health check, and a decision line indocs/decisions.md.DONE:
tea_file_editis on prod (mcp.teahub.xyz says version 2.3.0 and lists the tool), the unit tests pass on the merged SHA, andventures/havre/app.jsonhavre-nexthas been edited with it to sha25612023d935a811658(174102 bytes), checked from the Mac.Receipt so far (2026-10-16, late):
tea-file-edit:fcd70f294df6(server.py, README.md),060f3a3154ac(tests). PR #13 is open; the session that wrote it was not allowed to merge its own pull request, so the merge waits for the owner.requirements.txt+ pydantic + starlette in a venv); the checkout went to the Trash. This Claude sandbox cannot reach PyPI, so they could not run there.mcp/tea_mcp/server.pyc48dbba4668d4a7e,mcp/tests/test_server.py667420bc15ba5f22,mcp/README.md05fdff2bf5d273e2.The job this is for, to run once the tool is on prod:
tea_file_editonteahubxyz/time, branchhavre-next, pathventures/havre/app.js, with the ten edits below (derived from the branch file, sha256 7ee643b866c72a29, 172097 bytes, and checked to rebuild the new file exactly). The answer's sha256 must start12023d935a811658and its size must be 174102. Message: "havre: what venice saw under a refused photo or clip" (+ the session's attribution lines).The six other Havre files of that change are already on
havre-next(abilities.js, venice.js, style.css, skill.md, tests/abilities.test.mjs, tests/smoke.mjs); app.js referencesabilities.js filterWords, so refresh must wait for it.Done (2026-10-16, 23:35Z).
tea_file_editon prod applied the ten edits above toteahubxyz/timehavre-nextventures/havre/app.jsin one call: commit4f75ea4546b56c4636f3789066998649648e3ce7, answer size 174102, sha25612023d935a81165880596f4651ea68583f4fd4d673c8ad7c47499783334d29fb— matches the expected12023d935a811658; re-hashed from the Mac off the raw commit URL: 12023d935a811658, 174102 bytes.Published: refresh run 462 (slug havre, from havre-next), success, 23:30:53Z → 23:33:55Z.
Live hashes (sha256, first 16, from the Mac, cache-busted): app.js
12023d935a811658· abilities.jsdc735d1441ae0d44· venice.js3b1696120a578130· skill.md17fe5e2a4aec9493· style.css48e34ce649cbdd0b· index.htmlfa5718b23bcdaabd· card.js4a9d3618963487e9— all as expected.Boot check (headless Chrome on hb-5, temp folder trashed):
dom bytes 30574 · ids 147 of 147 missing: [] · empty tiles 8 · studio pane-list True · chat-back ← characters.Ledger:
docs/decisions.mdentry for MCP 2.3.0 /tea_file_edit, commita801cccd25a6(also made withtea_file_edit, on the file's last 300 characters). The DONE line is met: the live app.js hash matches. Closing.