Demos execute JS and need a browser; the app's own binary now provides
one: main.cjs boots a hidden window + CDP port under --browser-service
(before the single-instance lock - a helper is deliberately a second
instance of this binary), lifetime tied to the server's stdin (EOF =
quit, no orphans). headless.ts connects over CDP as PRIMARY (owner
ruling 2026-10-10), system Chrome stays the fallback, graceful refusal
when neither. Electron's CDP has no Target.createTarget, so the helper's
pre-created window is THE page, reused and reset per job behind a
serialized queue. PDFs and SVGs never reach a browser anymore (native
since stages 1-2); stale Chrome wording in model-facing tool copy made
engine-neutral. Live-proofed end-to-end from the dev tree (helper seen
by process watch, js-ran 42 probe, no orphan). Gates 1523/1523.
The whitehead incident (2026-10-09): glm-5.3 emitted quiz type with
quotes baked in ('"predict"') — rejected ten times, byte-identical
retries, the lesson never started — and correctAnswer with the JSON
key-value colon glued on ([':parmenides']). Third GLM wobble incident,
and each prior heal was wired only where its incident landed; this one
landed precisely where the practice heal never reached.
Layer 1 — healEncodedArguments moves to core/argument-heal (the display
seam needs it too — the tool-parts law) with withArgumentHeal +
healToolRecord (agent/tools/heal) wired at EVERY registry: prober,
teacher, practice, bareQuestionTools (it had no prepareArguments at
all), demo-builder, and submit-agent (composing after
healStringifiedArrays; the auditor's extraTools wrapped).
Layer 2 — stripModelValueDebris (core/value-debris): leading/trailing
colons, quotes, backticks, whitespace strip at the MEMBERSHIP seams —
coerceCorrectAnswer (boundary check + display + grading in one),
normalizeQuizType (persisted wobbled records keep their role),
resolveTargetId (copy-syntax on targets).
Layer 3 — schema description hygiene: the quiz type and practice
mode/outcome/rung/missClass values now read as bare words — the quoted
JSON-key style invited quote-copying.
Display mirror — parseQuizInput heals the raw record on read, so a
boundary-healed quiz whose record keeps raw args renders instead of
becoming an invisible pending quiz (learner lockout).
Pins: tests/tool-heal.test.ts (the incident shapes end-to-end through
pi-ai validation, the wiring, the strips, the mirror). The stringy-
options pins flip to boundary-parity per tool-parts' own law; the
chat-flow garbage pin keeps its honest-reject intent with an unhealable
shape. 1471 tests, typecheck, lint green.
pi-ai 1.0's onProviderStreamEvent, at the layer our incident class lives
(GLM arg truncation, DeepSeek id resets) — env-gated (owner choice:
no app surface, off by default; raw events carry full prompts), one
JSONL per chat under data/debug/provider-events/ with a 20MB cap and a
run-start marker for record correlation.
Wired at every agent construction: teacher/prober turns (AgentBuildOptions.
chatId), jobs via LoopContext (researcher, demo-builder through
runSubagent; visual-maker + pdf-reader through their streamOnce), and
the extraction pipeline (extractor + auditor keyed by source id).
practice deliberately unwired (sunk-cost module).
- src/agent/lh-tool.ts deleted: the zod-wrapped LhTool + toPiTool bridge
(z.toJSONSchema per call, clientQuestion flag, hand-rolled result mapping)
is gone. Tools are pi-agent-core native AgentTools now.
- New src/agent/tool-execute.ts owns what the wrapper added: the abort guard
(runWithAbortGuard: run signal + 30s per-call timeout + 250ms grace,
'Operation aborted' / timeout wording preserved), ToolInputError (the
retryable '- problem at path' format), result mapping (string verbatim /
object JSON / image parts, details rides the record's tool part output),
and questionResult() — placeholder + terminate:true (owner Option A:
belt of terminate + slim makeQuestionStopper with the clean-question
rule; maxTurns deleted from the teaching turn entirely per owner).
- All 11 tool files + tools.ts registries converted to TypeBox parameters
(typebox@1.3.7, pinned to pi-agent-core's copy). zod refinements
(quizInputProblems, askOptionProblems, live-nodeId) moved from schemas
into execute — the model sees the same retryable error shape.
- question-core + contracts/probe TypeBox-authored (single source shared
with the UI); tool-parts parse seam runs the boundary's full chain
(Convert → Check → Clean → refinement), fixing the live-smoke lockout:
pi's boundary Value.Converts correctAnswer: 17 → '17' but the record
keeps raw args, so a parse without Convert rejected a posed quiz
(invisible pending question, turn ended). Pinned with the real smoke
emission shape.
- Subagents converted: demo-builder/researcher AgentTool-native,
visual-maker/pdf-reader declaration-only pi-ai Tools with local execute
fns, extraction submit schemas re-authored in TypeBox (owner option 2:
zod defaults → Type.Optional + fill at the submit seam).
- Accepted delta, flagged: TypeBox-native schemas skip pi's JSON-Schema
primitive coercion fast path... (boundary still Value.Converts), and
prober-quiz unknown keys ride through (Value.Clean strips at display).
- Tests: 988 passing (was 967 at the recon baseline); tool-abort and
read-tool rewritten against the native seams; question-core/question-
tools/tool-parts/demos/web-search/graph-tools/plan-tree/probe-done/
tool-toggles converted to Value.Check/execute pins.
Live smoke (PORT=3004, real model): native ls turn streams end-to-end; a
real quiz pose (first attempt missing correctAnswer → tool-input-error →
model retry → posed) terminates the turn cleanly and persists pending.
The Vercel AI SDK survives only as Band D (useChat client + UIMessage
record types); @openrouter/ai-sdk-provider is uninstalled.
- src/agent/models.ts: PiEngine {model, streamFn} via createModels +
openrouterProvider subpath; thinkingLevelFor maps config effort none->off
- src/agent/lh-tool.ts: neutral LhTool (zod authoring, optional execute,
clientQuestion flag); toPiTool converts via z.toJSONSchema and re-runs
zod inside execute so refinements ride the error-result retry path
- src/server/pi/record-adapter.ts: UIMessage records -> pi Messages
(dangling tool calls get synthesized interrupted results)
- src/server/pi/ui-stream.ts: SSE bridge emitting AI-SDK-shaped chunks so
the useChat client parses unchanged (continuation id rule, question
tools never emit output, tool-input-error for bad authoring)
- teacher-agent.ts: stateless per-turn pi Agents; shouldStopAfterTurn
stops only when every question-tool call resolved cleanly (errors retry)
- chat-handler.ts: buildAgent(outgoing) factory seam (stateless doctrine);
assertValidUiMessages owns the retired SDK validation gate
- sub-agents via shared runSubagent (pi-run.ts); extraction stages stop
after a clean submit; visual-maker keeps its hand-rolled loop over
direct streamSimple calls; aborted model calls bill no usage
- tests: all model seams ported to pi-ai faux provider (scripted
FauxResponseSteps, agent factories); handleChatRequest second param
is now an agent factory
Gates: tsc, lint, build, 305/305. Live GLM smoke (disposable driver,
deleted): prober streaming + persistence, question seam, turn-2
continuation same message id, per-turn real token usage - ALL PASS.
Data wiped at the pivot (chats/graph/jobs, .gitkeeps kept).
Wave 4 (agent-side observability + control), reconciled with the dock
contract (master relay 2026-09):
- abortJob in src/server/jobs.ts, exported through the server barrel for
POST /api/activity/abort (master's thin route): fires the job's
AbortController (real kill — the in-flight model call aborts, not just
the next loop check), drops any queued result steer, flips the record
to error "Aborted by learner" + activity "aborted" synchronously, and
queues a one-line abort steer (buildAbortSteer) through the same wake
pipeline so the teacher's promise doesn't hang in the lesson. Shares
the result steer's deterministic id space, so abort + racing result
can never both deliver (steerDelivered dedupe).
- Abort registry (src/agent/subagents/aborts.ts) on globalThis, mirroring
the wake pipeline's module-state pattern; runner registers/unregisters
per run, so the route's module instance sees the loop's controller.
- Runner usage capture: LoopContext (abortSignal + onUsage) threads into
all three kind loops; per-step usage folds via accumulateJobUsage into
JobRecord.usage (model from config MODELS, absent-never-zero), flushed
in runJob's finally so an aborted job's money record survives the kill.
- Contracts: JobUsage token fields optional + lastStep (most recent
call's accounting, for the dock's per-blob context line vs
CONTEXT_LIMITS); SubagentResultMeta.aborted; AbortSteerMessage;
buildAbortSteer; SteerMessage union generalized.
- demo-builder/visual-maker accept abortSignal + onUsage (visual folds
generateText usage per iteration).
- stillRunning/finishJob/jobContext guards: an aborted job is never
overwritten by the runner (terminal no matter what the wake does);
failJob and the pump's finalizeJob keep the flip.
- UI: abort steer renders a quiet aborted chip (job-aborted-badge).
- Prompt 20-jobs.md: one line about aborted jobs.
Tests: accumulateJobUsage folds (undefined-preservation, lastStep),
runner usage capture, real-kill abort (sync flip, silent unwind,
abort steer wakes), usage-so-far survives the kill, aborting a
delivering job drops the queued result steer, outcome reasons,
abort-chip render. 330/330, typecheck/lint/build green.
MODELS in config.ts is now THE single tuning table: model id + OpenRouter
reasoning effort for teacher/researcher/demoBuilder/visualMaker/extractor/
auditor. .env.local is secrets-only (LH_* model vars removed; all sub-agents
previously had NO reasoning setting -> provider-default unbounded thinking,
the extraction token burn). One factory (src/agent/models.ts modelFor) wires
all six call sites; test injection unchanged. EXTRACTION.everyNQuizAnswers
moved to a config literal too. Plus the §5 one-line-per-seam logging in the
extraction pipeline: pipeline start, extractor/auditor start + per-step,
audit kept, merged — previously silent until completion, which made live
runs look like they never fired.