Repository navigation
Conversation
|
@Stener1 is attempting to deploy a commit to the seanbrydonio's projects Team on Vercel. A member of the Team first needs to authorize it. |
5e4a92c to
2e806be
Compare
sean-brydon
left a comment
There was a problem hiding this comment.
Thanks, this is a really thorough PR. Could you split it in two?
Codex turns: happy to take this. notify firing per message explains the Codex turns we've seen showing Done while still working, and the fix looks right. Please open it on its own, rebased on main (it conflicts now).
Turn check: holding for now. The key handling is careful, but:
- It can send secrets out. PUT /v1/turncheck is also reachable on berthd's local socket, so any agent on the box can call it. It could point url at its own server with key: op://… and receive the secret plus later transcripts. It needs: laptop-only settings (refuse wire.IsLocal), and secret references only sent to the default gateway.
- Transcripts go out unredacted. Please cap the prompt and run both the prompt and the reply through the same secret detection artifacts use.
- I'd rather not default to one vendor's model for users' transcripts. Let the user choose the model.
Send the Codex half and I'll merge it quickly. Thanks again!
|
@sean-brydon Great! Apologies for the long time it took, I didn't realize a comment had come in, but I'm very happy to be contributing now that I saw it. Will fix straight away. |
…picks no model From the review on cosscom#7: - GET and PUT /v1/turncheck refuse the box's own socket. Any agent on the box can reach it, and could otherwise point url at its own server with key: op://… and collect the secret and later transcripts. - A key, pasted or a reference, goes only to the default gateway (AI Gateway's evaluate URL). Settings refuses a reference beside another url, and a check whose turncheck.json says so anyway sends nothing, so editing the file on the box can't collect one either. - The prompt is capped at 4,000 characters and the title at 300, and the title, prompt and reply go through RedactSecrets first: the same patterns that refuse an artifact, with a private key taken out whole. A turn that still looks like it holds a secret isn't sent. - No default model: turning it on needs one, and Settings → Agents asks for it before the switch can be turned on. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V7UV6sY16AabsdHtcyxyjL
|
@sean-brydon the Codex half is now its own PR: #71 (rebased on main, ready to merge). This PR is turn check only, with your three points addressed; see the description. |
…picks no model From the review on cosscom#7: - GET and PUT /v1/turncheck refuse the box's own socket. Any agent on the box can reach it, and could otherwise point url at its own server with key: op://… and collect the secret and later transcripts. - A key, pasted or a reference, goes only to the default gateway (AI Gateway's evaluate URL). Settings refuses a reference beside another url, and a check whose turncheck.json says so anyway sends nothing, so editing the file on the box can't collect one either. - The prompt is capped at 4,000 characters and the title at 300, and the title, prompt and reply go through RedactSecrets first: the same patterns that refuse an artifact, with a private key taken out whole. A turn that still looks like it holds a secret isn't sent. - No default model: turning it on needs one, and Settings → Agents asks for it before the switch can be turned on. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V7UV6sY16AabsdHtcyxyjL
|
@sean-brydon updated: this is now turn check only, rebased straight on main (the Codex part is #71). On point 3, I went further than a free-text model field, since AI Gateway's evaluate endpoint only serves Jev. Settings → Agents now asks who checks turns, with nothing picked by default: Jev on AI Gateway, OpenAI Decisions ( |
Thanks man! Appreciate that - I'll look into reviewing this later today |
…picks no model From the review on cosscom#7: - GET and PUT /v1/turncheck refuse the box's own socket. Any agent on the box can reach it, and could otherwise point url at its own server with key: op://… and collect the secret and later transcripts. - A key, pasted or a reference, goes only to the default gateway (AI Gateway's evaluate URL). Settings refuses a reference beside another url, and a check whose turncheck.json says so anyway sends nothing, so editing the file on the box can't collect one either. - The prompt is capped at 4,000 characters and the title at 300, and the title, prompt and reply go through RedactSecrets first: the same patterns that refuse an artifact, with a private key taken out whole. A turn that still looks like it holds a secret isn't sent. - No default model: turning it on needs one, and Settings → Agents asks for it before the switch can be turned on. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V7UV6sY16AabsdHtcyxyjL
An agent's hooks report a turn as finished even when it ends by asking something or stops with its work unfinished. With turn check on for a box (off by default), berthd reads a finished Claude Code or Codex turn's last prompt and reply from its transcript and asks Jev on Vercel AI Gateway, with prompt training disallowed. needs_input, blocked or working at 0.65 or more publishes agent.waiting (source "turncheck", the choice, and the Seq of the turn's end); anything else, a failed, late or unsure answer included, leaves the session finished, and no other model is asked. The ledger takes the verdict only while the session is still finished at that Seq, and marks the wait soft: it shows as waiting, but sends, held prompts and the phone treat the agent as finished, so nothing deadlocks on it. The key is pasted in Settings → Agents and kept on the box (turncheck.key in the state directory, 0600, never answered, sent only to AI Gateway), or named by a secret reference (op:// or env://); the last one set wins. A key goes only over https and never follows a redirect. turncheck.json in ~/.berth holds the switch, the reference, and optionally the url and model of another Jev-compatible server. GET/PUT /v1/turncheck, before:turncheck.change, turncheck.changed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, not Idle
A finished turn Jev read as work still going on without the agent (a build,
a deploy, a check, a later time) went to agent state idle, so the Agent
Dashboard put it in Idle ("Agents open with nothing to do"), beside agents
that had never been prompted. On a real box that was about 40% of finished
turns, and Done looked empty.
The ledger now keeps such a turn finished and marks the session unfinished
(cleared by its next state), so it still takes prompts, ends waits and
delivers held prompts as done. Sessions carry `unfinished`; the app shows a
finished, unfinished agent as running everywhere (dashboard Working column
with "Waiting on its work", sidebar, counts), and the Review inbox leaves it
out until it is done. Done holds only work that is done.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ox8V6xEpeM41a6641PDPK
…le, and tells a wrap-up from work still going on Jev was judging many turns on the reply alone: the prompt was read from the last 60 transcript items, so a long turn (or one the agent resumed by itself after its background work reported back) sent an empty user_request, and session_objective was always empty. On a real box 45 of 189 checked turns went without a prompt. The check now looks back up to ten pages for the prompt (10 of 189 still have none), and sends the session's title as the objective. The instructions now say that a wrap-up, a handoff or "the queue is empty, send the next one" is done even with follow-ups listed, and that a reply waiting for the user's go-ahead or leaving something waiting on them needs input. Replayed against Jev on those 189 turns, about 10 move to the right column (e.g. "Waiting for the go-ahead to continue" from Working to Needs you, "The queue is empty" from Working to Done) and about 3 to a worse one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013ox8V6xEpeM41a6641PDPK
…picks no model From the review on cosscom#7: - GET and PUT /v1/turncheck refuse the box's own socket. Any agent on the box can reach it, and could otherwise point url at its own server with key: op://… and collect the secret and later transcripts. - A key, pasted or a reference, goes only to the default gateway (AI Gateway's evaluate URL). Settings refuses a reference beside another url, and a check whose turncheck.json says so anyway sends nothing, so editing the file on the box can't collect one either. - The prompt is capped at 4,000 characters and the title at 300, and the title, prompt and reply go through RedactSecrets first: the same patterns that refuse an artifact, with a private key taken out whole. A turn that still looks like it holds a secret isn't sent. - No default model: turning it on needs one, and Settings → Agents asks for it before the switch can be turned on. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V7UV6sY16AabsdHtcyxyjL
…ecisions or your own Laya There is no default provider or model any more: Settings → Agents asks who checks turns first. - gateway: Jev through Vercel AI Gateway's /v1/evaluate, as before. - openai: OpenAI's Decisions API (POST /v1/decisions, gpt-6-luna), as one choice question over the turn as one text; its answer is read for the probability of its choice, and a refusal leaves the turn finished. - laya: Laya's laya-serve, or another server that answers Jev's request, at a URL you give, with its model optional (Laya routes by itself). A key is kept per provider (turncheck.key, turncheck-openai.key) and is sent only to that provider's own fixed URL; a URL in turncheck.json never changes where a key goes, and Laya gets none. A reference set for one provider is dropped when the provider changes, and switching provider turns the check off until it is turned on again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V7UV6sY16AabsdHtcyxyjL
A finished turn whose work is unfinished but needs nothing from you is now
one of two answers. working: something it started still runs (the agent, a
delegated task, a build, a deploy, a test run, a watcher), shown as Working
(unfinished: true). parked: nothing runs and it waits on someone else's
reply or a later time ("check again in 24 hours"), shown as Idle
(parked: true). Both stay finished, take prompts, never notify, and stay
out of Review. Jev, Laya and OpenAI Decisions are asked with both
definitions.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LQ7gUBM8j8uAh3PWTFSYQa
The "+N more" button under a Home widget's rows was 18px tall, under the 24px touch target axe asks for, so screens showing it (Home, the rail, the skills notice) failed the accessibility check once enough agents were listed. It keeps its look, with a 24px minimum height. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LQ7gUBM8j8uAh3PWTFSYQa
A parked agent waits on a reply or a later time on purpose, and now sits in Idle, which Clean up offers. It is listed, marked parked, and only stopped when ticked by hand. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LQ7gUBM8j8uAh3PWTFSYQa
|
@sean-brydon a heads-up before you review: I rebased on main (only a changelog conflict) and added three commits on top.
Checks: Go build, vet and the turn-check and review tests pass; the app builds and its unit tests pass (392/392); 🤖 Generated with Claude Code |
Closes #6. Refs #8 (its hook findings; the shared-daemon part of #8 is separate).
Split as asked: the Codex half is its own PR, #71. This branch is now on main without it, so the diff is turn check only.
Choose who checks turns (new, last commit)
Point 3 asked for no default vendor model. A model field alone wasn't a real choice, because AI Gateway's
/v1/evaluateonly serves Jev, so Settings → Agents now starts with a provider picker. Nothing is picked for you:https://ai-gateway.vercel.sh/v1/evaluatetypesafe-ai/jevhttps://api.openai.com/v1/decisions(public beta)gpt-6-lunalaya-serveURL you give (/v1/systemone, Jev's shape)english,multilingual,typed-decisions)choicequestion over the turn as one text. Its answer is read for the probability of its choice, against the same 0.65 threshold, and arefusalleaves the turn finished.turncheck.key,turncheck-openai.key) and sent only to that provider's own fixed URL. Aurlinturncheck.jsonnever changes where a key goes, and Laya never gets one. A reference set for one provider is dropped when the provider changes, and switching provider turns the check off until you turn it on again.TestTurnCheckAsksOpenAIDecisions,TestDecisionsVerdictReadsOnlyItsAnswer,TestTurnCheckKeepsAKeyPerProvider(a pasted AI Gateway key is never sent to OpenAI),TestTurnCheckSendsAKeyOnlyToItsProvider; e2esettings-turn-checkcovers both flows (OpenAI with a key, then switching; Laya with only an address).laya0.4.6 (laya-serve, CPU) on a box, and berthd's turn check reached it and read its answers. Its quality on this task is weak, though. On 4 hand-made turns (a question, a finished fix, a running deploy, a blocked migration) it got 1–2 right per checkpoint and was never above 0.52 sure, so with the 0.65 threshold it left every turn Done: safe, but it changes nothing in practice. The Settings text doesn't oversell it.Changes from the review
GETandPUT /v1/turnchecknow refusewire.IsLocal(403, "turn check is set from a paired laptop"), so an agent on the box can't read or change where turns go. A key, pasted or a secret reference, is sent only to its provider's own fixed URL (AI Gateway's or OpenAI's);urlis used only for a Laya server, which never gets a key. PUT refuses a key with Laya and aurlwith a hosted provider. Sinceturncheck.jsonis still a file an agent on the box could edit, the check refuses too: a reference beside a Layaurlsends nothing at all, and aurlbeside a hosted provider is ignored.TestTurnCheckIsSetFromALaptopOnlydrives both routes over a real Unix socket;TestTurnCheckSendsAKeyOnlyToItsProviderwrites your exact scenario into the file (own server,key: env://…, over http and https) and asserts that server is never asked.RedactSecretsfirst, on the full text before cutting: the samesecretPatternsthat refuse an artifact (artifactcheck.go), each match replaced with[redacted: <what>], plus a private key taken out whole, header to footer, since the artifact pattern only needs the header to refuse. IfLooksLikeSecretstill finds anything after that, the turn isn't sent.TestTurnCheckSendsNoSecretAndAShortPromptcovers an AWS key, a GitHub token, an Anthropic key in the title, an.envline and a PEM block.jevModelis gone. Turning it on needs a provider and, for the hosted ones, amodel(400 otherwise), and a check with none sends nothing. See the provider picker above.Docs (
security.mdx,sessions-and-agents.mdx,app-api.mdx,config.mdx,events.mdx, changelog) are updated to match.Rebased on main after #73–#77. Turn check reads a session's transcript through
transcriptFile(ctx, …)like the other callers now. Structured Labs agents (the new Claude driver) aren't turn-checked yet: it only reads Claude Code's and Codex's own transcript files. Happy to add the driver's record if you want it.After the rebase:
go vet ./...andgo test -race ./...pass, exceptTestAStructuredSessionGoesToItsTerminalAndBack("the terminal's turn in the chat never happened"). That test fails the same way on a cleanmainhere, so it isn't this branch. The turn-check e2e specs pass.Tested before the rebase:
go vet ./...,go test -race ./...,pnpm test,pnpm build,typecheck:plugins,check:titles,check:csp,check:themes,tsc -p e2e; e2esettings-turn-check,dashboard-turn-checkandskills-notice(same Settings page), 66 of 66 over 3 repeats. Live: Laya as above. Not re-run live against Jev or OpenAI after these changes.An opt-in turn check: when an agent's turn finishes, berthd asks a calibrated classifier what the turn's end means, and if it is confident, the session shows as Needs you (it asks you something or is blocked) or Working (its work goes on without it: a build, a deploy, a later time) instead of Done. Off by default; nothing is sent unless it is turned on.
What it does
agent.finished(not error or interrupted) for Claude Code or Codex, berthd waits for the reply to reach the transcript (the notifier's settle), reads the turn's last prompt and reply, and asks the model you chose through Vercel AI Gateway's evaluate endpoint (POST /v1/evaluate, Jev's request and answer, prompt training disallowed). There is no default model.needs_inputorblockedpublishesagent.waiting(Needs you);workingpublishesagent.ready, which keeps the turn finished and marks the sessionunfinished(shown as Working; see the last section), both withsource: "turncheck", the choice and the finished event'sseq.workingafter a finished turn means the agent stopped with its work unfinished: a build still compiling, something to happen first, work parked until a later day, or work it resumes by itself. The instructions sent to Jev say so explicitly; on 15 real finished turns that wording put every one in the column we would put it in by hand (it moved a "we'll wait until Monday" turn from Done to Idle and one "paused until the import finishes" turn from Needs you to Idle, and changed nothing else) (Codex does when a background terminal finishes, with no new prompt hook). That is neither done nor a question, and Idle ("agents open with nothing to do") was otherwise almost always empty. An earlier version sent it to Needs you, which put busy Codex sessions there.done,ready_to_shipandidleleave the turn Done. The phone app labels Idle "ready for a first prompt", which reads a little oddly for these; happy to adjust if you prefer a different column or wording.idle()and thenow/idlesend gates treat it as finished, since the agent is at its prompt, not at an approval. (Otherwise a lead that ends with "teammates are on it" would hold its teammates' reports.)seq; the ledger applies them only if the session is still finished at that seq, insidePublish, so a prompt you send meanwhile always wins.Settings → Agents → Turn check
Per box: paste an AI Gateway key and Save ("Key saved on ", Remove), then switch it on. The key is kept on the box in the state directory (
turncheck.key, 0600), is never returned by the API, logged or put in an event, and goes only to the AI Gateway over https. Use a secret reference instead takesop://…orenv://NAME. A Model field comes first; the switch stays off until one is saved.urlinturncheck.jsoncan point it at another server that answers the same request (e.g. a self-hostedlaya-serve); that server never gets a key.Privacy
This is the first feature that sends transcript text off the box, so it is opt-in per box and documented next to the existing promise in
security.mdxandsessions-and-agents.mdx. Hooks stay content-free;TestTranslateNeverCopiesContentis unchanged.Tested
go vet ./...;go test -race ./internal/box/(incl. 12 turn-check tests: needs_input/blocked → waiting, working → idle, stays finished for done/low confidence/server errors/timeouts, no request when off, late answer dropped, soft wait letsidleandnowsends through, request shape, key only over https and only to the gateway, stored key 0600 and never returned);pnpm -C app build,pnpm -C app test(160 pass).env://reference, and for the last runs pasted in the new Settings screen with the environment variable removed): turns ending on a plain-text question went to Needs you 9 of 10 times (Jev 0.78–0.95); the one miss was on an earlier build of this branch, gave no log line, and did not recur in the 9 runs after it. Every turn that was really finished stayed Done (5 of 5); a send to a flipped session was delivered at once.Unrelated to this change,
TestATeamSetupResumesAfterBerthdRestartsfailed under-raceon a cleanmainon one run here and passed on another.workingshows as Working, not IdleRunning the earlier version on a busy box, about 40% of finished turns (65 of ~170 over three days) went to Idle. Idle's own text is "Agents open with nothing to do", so finished work looked like it had disappeared from Done. Replaying all 146 verdicts through this branch's own transcript code showed Jev was mostly right: about 43 of 53
workingturns really were waiting on a background job, CI, a deploy watch or a scheduled run. The problem was where they went.agent.readyno longer moves the session toidle. The turn staysfinished, and the session getsUnfinished, which the next state change clears. So the turn still takes prompts, ends waits and delivers held prompts as done. Theseqguard is the same as for the waits.Session.unfinished(also in@berth/plugin'sSession).sessionStatereads finished+unfinished asrunning, so the dashboard's Working column, the sidebar, counts and Home all agree. The card shows "Waiting on its work" with a tip saying it picks up by itself. Done holds only work that is done.allis asked for.Tested
TestTurnCheckMarksWorkStillGoingOnUnfinished(stays finished and ready for a prompt,enrichsays unfinished, cleared by the next turn, a verdict on an older turn is ignored); the Review test extended, and it fails without the filter;derive.test.ts; newe2e/dashboard-turn-check.spec.ts(the card is in Working with the marker and not in Done; a done card stays in Done), 5 of 5 runs.go vet ./...,go test -race ./...;typecheck:plugins,check:titles,check:csp,check:themes,pnpm test,pnpm build,tsc -p e2e, the docs build; e2e: rail, home-widgets, first-run, idle, pr-review and the new spec (26 passed).TestReviewRoutesOverTheWireandTestPaneDeadOfASignalIsItsShellStatusfailed once each in the full-racerun and passed 10 of 10 alone, on this branch and on the commit before it. They're flaky under load and unrelated to this change.What Jev is given
Jev was often judging a turn on its reply alone.
user_requestcame from the last 60 transcript items, so a long turn, or one the agent resumed by itself when its background work reported back, sent it empty: 45 of 189 checked turns on a real box.session_objectivewas always empty.session_objective. Docs,security.mdxand the Settings text say the title is sent.done, even with follow-ups listed. A reply that asks, waits for the user's go-ahead or leaves something waiting on them isneeds_input, even with work going on in the background.workingneeds the agent itself to be waiting on or going on with something specific.TestTurnCheckSendsTheLongTurnsPromptAndTheObjective(200 tool calls between the prompt and the reply; it fails without the lookback).go vet,go test -race ./...(only the known flakeTestPaneDeadOfASignalIsItsShellStatus),pnpm test,pnpm build, the docs build.🤖 Generated with Claude Code
https://claude.ai/code/session_01V7UV6sY16AabsdHtcyxyjL