Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ This repo is a CortexKit-maintained Anthropic auth monorepo for OpenCode and Pi.

- A successful token refresh no longer fails a request just because a quota check finished while it was refreshing.
- Valid tokens no longer wait for proactive refreshes; requests waiting for a rejected-token refresh fail retryably after two seconds while the refresh continues.
- Prevent repeated long replies after housekeeping calls on supported OAuth models in OpenCode 1, OpenCode 2 and Pi. Add a turn-scoped reminder after tool results when the reply has at least 350 visible characters and every call is in `turnEndNote.tools` (default `board` and `ctx_reduce`; an empty list disables it). Replay earlier reminders unchanged for prompt caching, except when a reminder would sit directly before a user message; Anthropic rejects that placement. API-key routes are unchanged.


### Dependencies

Expand Down
9 changes: 9 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,20 @@ The imported preference fields are listed below. Values such as quota thresholds
| `dump`, `logging` | `enabled` request dumps; `level`: `error`, `warn`, `info`, `debug`, `trace` |
| `claudeFast` | `enabled`: supported Opus fast mode |
| `thinkingBinding` | `prefixMismatchBehavior`: `account-default`, `error`, `drop_block` |
| `turnEndNote` | `tools`: host tool names for the reply-then-housekeeping note; default `["board", "ctx_reduce"]`, `[]` disables it |
| `costZeroing` | `enabled`: OpenCode OAuth price display, true by default |
| `relay` | `enabled`, `url`, `transport` (`http` / `websocket`; when missing, inferred from the URL: `wss://` uses `websocket`, `https://` uses `http`), `fallbackToDirect`; its token is private credential state |

[Cache](cache.md), [models](models.md) and [transports](transports.md) describe the effects. `quota.showToasts: true` opts into OpenCode quota notifications. Local credential state and per-account runtime metadata are separate from preferences; quota, profile, validation and Prime state are not all in the same file.

### Reply-then-housekeeping note

On OAuth routes for Fable 5.1, Opus 5.5, Sonnet 5.5 and Haiku 5.5, the plugin adds a turn-scoped system note after the results of an assistant's housekeeping calls when its visible reply is at least 350 characters and **all** its calls are in `turnEndNote.tools`. It asks Claude not to repeat the reply it already delivered. The 350-character minimum is a safety margin for short mid-work steps, not a configurable setting. Only non-whitespace text blocks count, joined without extra separators and measured as sent; thinking does not count. API-key routes, including API-key proxies, are unchanged.

Use the tool names the host calls, such as `board`, not the OpenCode wire spelling `mcp_Board`. For a custom list, set `"turnEndNote": { "tools": ["board", "my_cleanup"] }` in the live native settings; there is no menu control. An unset list uses `board` and `ctx_reduce`; an empty list turns the note off. Changing the list can change earlier notes, so keep it stable during a conversation to preserve prompt caching.

The plugin reconstructs every earlier qualifying note with identical text and placement on every request, independently of the current tool declarations. It does not add a cache breakpoint or effort configuration to the note. There is one exception: Anthropic rejects a note immediately followed by a user message, so the plugin omits just that note if the host appends a user message without an intervening assistant message. Bytes change at that point only; earlier notes are not moved or rewritten.

## Fallback accounts and session affinity

Fallbacks are separate OAuth accounts or Anthropic-compatible API-key routes in the active custody source. By default the main account is tried first, then eligible OAuth fallbacks in order. `fallback-first` reverses that preference. `sticky-balanced` assigns a new session using remaining quota above configured minimums in five-hour, seven-day and model-scoped windows.
Expand Down
48 changes: 48 additions & 0 deletions mutations.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,54 @@ prebuild = [
{ name = "opencode-current-source", command = ["bun", "run", "--cwd", "packages/opencode", "build"] },
]

[[control]]
id = "turn-end-note-replays-earlier-points"
guards = "Every qualifying history point must retain its turn-scoped note, not just the latest result."
file = "packages/core/src/turn-end-note.ts"
old = " if (visible.length < TURN_END_NOTE_MIN_VISIBLE_CHARACTERS) continue"
new = " if (index !== messages.length - 1) continue // NON-VACUITY BREAK: inject only at the latest point\n if (visible.length < TURN_END_NOTE_MIN_VISIBLE_CHARACTERS) continue"
test_file = "packages/core/src/tests/turn-end-note.test.ts"
runner = "command"
command = ["bun", "scripts/run-mutation-bun.ts", "packages/core/src/tests/turn-end-note.test.ts", "{test}"]
test_count_pattern = "CKDEV executed {count} tests"
expect_red = ["turn-end note history > replays every earlier note with an identical serialized prefix"]
expect_message = "error: expect(received).toBe(expected)"
broad_command = ["bun", "scripts/run-mutation-bun.ts", "--broad", "packages/core", "tmp/mutations/core.xml"]
broad_report = "tmp/mutations/core.xml"
broad_id = "{name}"

[[control]]
id = "turn-end-note-requires-all-tools"
guards = "A working tool mixed with housekeeping must prevent the turn-end note."
file = "packages/core/src/turn-end-note.ts"
old = " if (!calls.length || !calls.every((call) => allowed.has(call.name)))\n continue"
new = " if (!calls.length) continue // NON-VACUITY BREAK: drop the all-tools check"
test_file = "packages/core/src/tests/turn-end-note.test.ts"
runner = "command"
command = ["bun", "scripts/run-mutation-bun.ts", "packages/core/src/tests/turn-end-note.test.ts", "{test}"]
test_count_pattern = "CKDEV executed {count} tests"
expect_red = ["turn-end note history > requires all tool calls to be housekeeping"]
expect_message = "error: expect(received).toBe(expected)"
broad_command = ["bun", "scripts/run-mutation-bun.ts", "--broad", "packages/core", "tmp/mutations/core.xml"]
broad_report = "tmp/mutations/core.xml"
broad_id = "{name}"

[[control]]
id = "turn-end-note-requires-long-reply"
guards = "Short mid-work text must not receive a reply-complete note; the safety margin is 350 visible characters."
file = "packages/core/src/turn-end-note.ts"
old = " if (visible.length < TURN_END_NOTE_MIN_VISIBLE_CHARACTERS) continue"
new = " if (visible.length === 0) continue // NON-VACUITY BREAK: drop the 350-character check"
test_file = "packages/core/src/tests/turn-end-note.test.ts"
runner = "command"
command = ["bun", "scripts/run-mutation-bun.ts", "packages/core/src/tests/turn-end-note.test.ts", "{test}"]
test_count_pattern = "CKDEV executed {count} tests"
expect_red = ["turn-end note history > requires at least 350 exact outgoing visible characters"]
expect_message = "error: expect(received).toBe(expected)"
broad_command = ["bun", "scripts/run-mutation-bun.ts", "--broad", "packages/core", "tmp/mutations/core.xml"]
broad_report = "tmp/mutations/core.xml"
broad_id = "{name}"

[[control]]
id = "gzip-resets-cch-before-compression"
guards = "The compressed body must carry cch=00000 rather than the identity body's signature."
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,12 @@ export type AccountStorage = {
thinkingBinding?: {
prefixMismatchBehavior?: 'account-default' | 'error' | 'drop_block'
}
/**
* Tool names (as the host names them) whose calls after a long reply get a
* system note telling the model not to repeat that reply. Unset means the
* `board` and `ctx_reduce` tools; an empty list turns the note off.
*/
turnEndNote?: { tools: string[] }
/**
* Zero out Anthropic OAuth model costs in the provider hook. Default: enabled
* (OAuth usage is quota-based, not per-token billed, so costs show as $0).
Expand Down
13 changes: 12 additions & 1 deletion packages/core/src/claude-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
sendsPerTurnControlBeta,
} from './mid-conversation-output-config.ts'
import { isClaudeHaiku55Model } from './models.ts'
import { TURN_END_NOTE_BETA } from './turn-end-note.ts'

export type ProviderAccountUuid = string & {
readonly __providerAccountUuid: unique symbol
Expand Down Expand Up @@ -397,7 +398,7 @@ export const CLAUDE_CODE_FULL_AGENT_BETAS = [
'prompt-caching-scope-2026-01-05',
'mid-conversation-tool-changes-2026-07-01',
'advanced-tool-use-2025-11-20',
'mid-conversation-system-clear-at-2026-08-21',
TURN_END_NOTE_BETA,
'effort-2025-11-24',
'thinking-binding-controls-2026-08-01',
'thinking-display-updates-2026-08-18',
Expand Down Expand Up @@ -460,6 +461,16 @@ export function selectClaudeCodeBetas(
? [...CLAUDE_CODE_QUOTA_BETAS]
: [...CLAUDE_CODE_TITLE_BETAS]

if (
Array.isArray(body?.messages) &&
body.messages.some(
(message) =>
isRecord(message) && message.clear_at === 'next_user_message',
) &&
!selected.includes(TURN_END_NOTE_BETA)
)
selected.push(TURN_END_NOTE_BETA)

// Claude Code 2.1.302 puts claude-code-20250219 after the prompt-caching
// scope beta on Haiku 5.5 requests instead of first.
if (body && isClaudeHaiku55Model(body.model)) {
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,3 +311,4 @@ export {
export * from './start.ts'
export * from './sticky-routing.ts'
export * from './thinking-binding.ts'
export * from './turn-end-note.ts'
1 change: 1 addition & 0 deletions packages/core/src/native-account-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,7 @@ const settingFields: Readonly<Record<string, readonly string[]>> = {
logging: ['level'],
claudeFast: ['enabled'],
thinkingBinding: ['prefixMismatchBehavior'],
turnEndNote: ['tools'],
costZeroing: ['enabled'],
cacheKeep: ['enabled', 'always', 'startHour', 'endHour', 'subagents'],
prime: ['enabled'],
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/native-migration-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ const settingFields: Record<string, readonly string[]> = {
logging: ['level'],
claudeFast: ['enabled'],
thinkingBinding: ['prefixMismatchBehavior'],
turnEndNote: ['tools'],
costZeroing: ['enabled'],
cacheKeep: ['enabled', 'always', 'startHour', 'endHour', 'subagents'],
prime: ['enabled'],
Expand Down Expand Up @@ -176,6 +177,12 @@ export function validateNativeMigrationSettings(
].includes(key)
) {
if (typeof value[key] !== 'boolean') refuse()
} else if (section === 'turnEndNote' && key === 'tools') {
if (
!Array.isArray(value[key]) ||
!value[key].every((name) => typeof name === 'string')
)
refuse()
} else if (key === 'url') {
if (typeof value[key] !== 'string' || !isValidApiBaseURL(value[key]))
refuse()
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/native-vault-seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ const preferenceFields: Record<string, readonly string[]> = {
// first settings write may not yet know which vault accounts have those UUIDs.
killswitch: ['enabled', 'main'],
claudeFast: ['enabled'],
turnEndNote: ['tools'],
prime: ['enabled'],
logging: ['level'],
dump: ['enabled'],
Expand Down
209 changes: 209 additions & 0 deletions packages/core/src/tests/turn-end-note.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
import { describe, expect, test } from 'bun:test'
import { selectClaudeCodeBetas } from '../claude-code.ts'
import { nativeAccountSettings } from '../native-account-view.ts'
import {
projectNativeMigrationSource,
validateNativeMigrationSettings,
} from '../native-migration-source.ts'
import { applyTurnEndNotes, supportsTurnEndNote } from '../turn-end-note.ts'

const note = {
role: 'system',
clear_at: 'next_user_message',
content:
'Your reply above has already reached the user. The tool call after it was housekeeping and changed nothing they need to know. Do not repeat, summarize or rewrite that reply. If no new work remains, end the turn without restating anything.',
}
function pair(id: string, names = ['board'], text = 'A'.repeat(350)) {
return [
{
role: 'assistant',
content: [
{ type: 'text', text },
...names.map((name, i) => ({
type: 'tool_use',
id: `${id}_${i}`,
name,
input: {},
})),
],
},
{
role: 'user',
content: names.map((_, i) => ({
type: 'tool_result',
tool_use_id: `${id}_${i}`,
content: 'Done',
})),
},
]
}
const request = (messages: unknown[]) => ({
model: 'claude-opus-5-5',
messages,
})

describe('turn-end note history', () => {
test('replays every earlier note with an identical serialized prefix', () => {
const history = [{ role: 'user', content: 'Question' }, ...pair('a')]
const first = request(structuredClone(history))
expect(applyTurnEndNotes(first)).toBe(1)
history.push(
{ role: 'assistant', content: [{ type: 'text', text: 'Done' }] },
{ role: 'user', content: 'Next' },
...pair('b', ['ctx_reduce']),
)
const second = request(structuredClone(history))
expect(applyTurnEndNotes(second)).toBe(2)
expect(
JSON.stringify(second.messages.slice(0, first.messages.length)),
).toBe(JSON.stringify(first.messages))
expect(second.messages.at(-1)).toEqual(note)
})

test('requires at least 350 exact outgoing visible characters', () => {
expect(
applyTurnEndNotes(request(pair('a', ['board'], 'a'.repeat(349)))),
).toBe(0)
const body = request(pair('a', ['board'], 'a'.repeat(350)))
expect(applyTurnEndNotes(body)).toBe(1)
expect(body.messages.at(-1)).toEqual(note)
const split = pair('b')
split[0]!.content = [
{ type: 'text', text: 'x'.repeat(175) },
{ type: 'text', text: ' \n' },
{ type: 'text', text: 'y'.repeat(174) },
{ type: 'tool_use', id: 'b_0', name: 'board', input: {} },
] as any
expect(applyTurnEndNotes(request(split))).toBe(0)
;(split[0]!.content[2] as any).text += 'y'
expect(applyTurnEndNotes(request(split))).toBe(1)
})

test('requires all tool calls to be housekeeping', () => {
const body = request(pair('a', ['board', 'bash']))
expect(applyTurnEndNotes(body)).toBe(0)
expect(body.messages).not.toContainEqual(note)
})

test('requires actual visible text and matching results, not just thinking or calls', () => {
for (const text of ['', ' \t\n'])
expect(applyTurnEndNotes(request(pair('a', ['board'], text)))).toBe(0)
const thinking = pair('a')
thinking[0]!.content = [
{ type: 'thinking', thinking: 'private' },
{ type: 'tool_use', id: 'a_0', name: 'board', input: {} },
] as any
expect(applyTurnEndNotes(request(thinking))).toBe(0)
expect(applyTurnEndNotes(request([pair('a')[0], pair('b')[1]]))).toBe(0)
expect(
applyTurnEndNotes(
request([
{ role: 'assistant', content: [{ type: 'text', text: 'Answer' }] },
{ role: 'user', content: 'Next' },
]),
),
).toBe(0)
})

test('supports only verified families and each injected note has its main beta', () => {
for (const model of [
'claude-opus-5-5',
'claude-sonnet-5-5-20261001[1m]',
'claude-fable-5-1',
'claude-haiku-5-5',
]) {
const body = { ...request(pair('a')), model }
expect(supportsTurnEndNote(model)).toBe(true)
expect(applyTurnEndNotes(body)).toBe(1)
expect(selectClaudeCodeBetas(body).split(',')).toContain(
'mid-conversation-system-clear-at-2026-08-21',
)
}
for (const model of [
'claude-opus-5',
'claude-sonnet-4-5',
'claude-mythos-5-1',
'unknown',
undefined,
]) {
expect(supportsTurnEndNote(model)).toBe(false)
expect(applyTurnEndNotes({ ...request(pair('a')), model })).toBe(0)
}
})

test('keeps the note byte shape free of cache and effort fields', () => {
const body = request(pair('a'))
applyTurnEndNotes(body)
expect(body.messages.at(-1)).toEqual(note)
expect(
selectClaudeCodeBetas(
{ ...body, max_tokens: 1, stream: false },
[],
'auxiliary',
).split(','),
).toContain('mid-conversation-system-clear-at-2026-08-21')
})

test('empty configuration disables; stateless host-name mapping allows custom tools', () => {
expect(applyTurnEndNotes(request(pair('a')), { tools: [] })).toBe(0)
const body = request(pair('a', ['Cleanup']))
expect(
applyTurnEndNotes(body, {
tools: ['cleanup'],
mapToolName: (name) => name[0]!.toUpperCase() + name.slice(1),
}),
).toBe(1)
expect(body.messages.at(-1)).toEqual(note)
})

test('the next-user exception removes only the offending note', () => {
const history = [{ role: 'user', content: 'Question' }, ...pair('a')]
const first = request(structuredClone(history))
applyTurnEndNotes(first)
const second = request([
...history,
{ role: 'user', content: 'Next' },
...pair('b'),
])
applyTurnEndNotes(second)
expect(second.messages.slice(0, 3)).toEqual(first.messages.slice(0, 3))
expect(second.messages[3]).toEqual({ role: 'user', content: 'Next' })
expect(second.messages.at(-1)).toEqual(note)
expect(
second.messages.filter((message: any) => message.clear_at),
).toHaveLength(1)
})

test('recognizes the assistant across empty effort controls without altering them', () => {
const [assistant, results] = pair('a')
const effort = {
role: 'system',
content: [],
output_config: { effort: 'low' },
}
const body = request([assistant, effort, results])
expect(applyTurnEndNotes(body)).toBe(1)
expect(body.messages).toEqual([assistant, effort, results, note])
})
})

test('turnEndNote settings survive secret-free native projection and migration, including empty arrays', () => {
for (const tools of [[], ['board', 'custom']]) {
const settings = { turnEndNote: { tools, secret: 'not-public' } }
expect(nativeAccountSettings(settings)).toEqual({ turnEndNote: { tools } })
expect(() => validateNativeMigrationSettings(settings)).not.toThrow()
const migrated = projectNativeMigrationSource({
host: 'opencode',
storageId: 'a'.repeat(64),
configDigest: null,
config: settings,
state: {},
hostAuth: {},
})
expect(migrated.settings.turnEndNote).toEqual({ tools })
}
for (const tools of ['board', [1], null])
expect(() =>
validateNativeMigrationSettings({ turnEndNote: { tools } }),
).toThrow()
})
Loading
Loading