mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-20 20:15:54 +00:00
fix: stop a stuck cursor, pick the right Claude session file, skip the Kimi blob walk
A Capacity Dock row that vanishes while hovered fires no hover exit, so the pushed pointing-hand cursor stuck system-wide; the row now pops it on the way out. Claude Code writes a fresh sessions/<pid>.json on every resume, so several files name one session and the first listed was taken; the newest is taken now, and a newest that fails the recycled-pid guard leaves the row inert. collectKimicodeInputs recursed the whole Kimi session store to find wire files, listing ~18k entries for ~2k wires at ~250ms per payload build. It now walks work dir, session and agent levels only: ~23ms, and a decoy wire.jsonl under blobs/ or file-history/ no longer reports itself as a session.
This commit is contained in:
parent
dca2db65f9
commit
97ace81fb6
6 changed files with 73 additions and 7 deletions
|
|
@ -30,8 +30,10 @@
|
|||
- **Every Copilot credential rung now follows its own GitHub host, not just the two read from files.** #1286 taught `hosts.json` and `apps.json` to carry the host their token came from, but the rungs that carried none — `COPILOT_GITHUB_TOKEN` / `GH_TOKEN` / `GITHUB_TOKEN`, `gh auth token`, and a token pasted into Settings — were still sent to `api.github.com`, so a user whose only Copilot credential is a GitHub Enterprise Cloud login got a 401 and a terminal failure with no way out. The environment rung now reads `GH_HOST` from the same environment as the token; the `gh` rung resolves the host from gh's own `hosts.yml` (`GH_CONFIG_DIR`, then `XDG_CONFIG_HOME/gh`, then `~/.config/gh`), picking it the way gh picks the host for `gh auth token` and without a second subprocess; and macOS Settings gains a GitHub host field next to the pasted token, defaulting to `github.com` and saved in the same Keychain record as the token so the two can never drift apart. Settings refuses a host no endpoint can be derived from at the field rather than storing it, the connection row no longer promises `api.github.com` before anything has been fetched, and every one of these new host sources is character-validated before a URL is built, so a crafted value such as `evil.com?.ghe.com` fails closed with no request instead of pointing the Authorization header at a host of its own choosing. `codeburn quota` is unchanged: it still reads only the editor-plugin files, which already carry their host. (#1306)
|
||||
- **Copilot live quota works for GitHub Enterprise Cloud enterprises on a `*.ghe.com` host.** Both readers hardcoded `https://api.github.com/copilot_internal/user` and threw away the host their credential came from, so a data-residency enterprise signed in on `<tenant>.ghe.com` could only ever report `available: false` with "Temporarily unavailable". A discovered credential now carries its host — `hosts.json` is keyed by host and newer `apps.json` files key by `<host>:<app id>` — and the request follows it: `api.github.com` for `github.com` and for any rung that carries no host of its own (an app-name `apps.json` key, `COPILOT_GITHUB_TOKEN` / `GH_TOKEN` / `GITHUB_TOKEN`, `gh auth token`, a pasted token), and `https://api.<tenant>.ghe.com/copilot_internal/user` for an enterprise host. The token and the host always come from the same entry, with `github.com` preferred when several hosts are signed in and otherwise the first `.ghe.com` tenant in sorted order; a host neither rule can address, such as a self-hosted GitHub Enterprise Server install, fails with a message naming that host instead of sending the credential to dotcom, and unreachable-host and HTTP failures name the host that was tried. The macOS Settings connection row now says which host answered. (#1286)
|
||||
- **OpenCode's session-level fallback now bills reasoning tokens at the output rate.** When none of a session's messages produce a priced call, codeburn falls back to the totals on OpenCode's `session` row, and that path handed `tokens_output` alone to the pricing table while still reporting `tokens_reasoning` separately, so a reasoning model's thinking showed up in the token breakdown but was never charged (4,000 reasoning tokens on Sonnet 4 left a $0.11 session priced at $0.05). OpenCode itself charges reasoning at the output rate and codeburn's per-message path already did; the fallback and the per-message path now both go through `billableOutputTokens`, the same rule the cache-rehydration path uses, so the three can't drift apart again. Displayed token totals are unchanged. (#1334)
|
||||
- **Finding live Kimi Code sessions no longer walks the whole session store.** The scan recursed through `<root>/sessions` looking for `wire.jsonl`, so every blob and file-history entry an agent had ever written was listed to find the wire files among them — about 18,000 entries for 2,000 wires, and roughly 250ms on every payload build. It now walks only the levels the layout actually uses (work dir, session, agent) and stats `wire.jsonl` there, which is about 23ms on the same store. A decoy `wire.jsonl` buried in `blobs/` or `file-history/` was also being reported as a session of its own, and no longer is; a missing store is still silent.
|
||||
|
||||
### Fixed (menubar)
|
||||
- **The pointing-hand cursor no longer sticks after a session row disappears under it.** The Capacity Dock's click-to-raise rows pushed the cursor on hover and popped it on exit, but a row that vanishes while hovered — the session leaves the ten-minute window, or the dock hides — never fires an exit, so the pushed cursor stayed on system-wide until something else replaced it. The row now pops what it pushed on the way out. Clicking such a row could also raise the wrong terminal: Claude Code writes a fresh `~/.claude/sessions/<pid>.json` on every resume and never removes the old one, so several files name the same session and the first one the directory happened to list was taken. The newest is now taken instead, and if its pid fails the recycled-pid guard the row stays inert rather than falling back to a stale file.
|
||||
- **A quota window that resets now refreshes now.** The menubar's 30s tick compared the reset times it already holds — Claude's 5-hour and weekly windows, Codex's two windows, every Capacity Dock provider's — against the previous tick, and forces a refresh for the provider whose window just rolled over instead of leaving the pre-reset percentage on screen for up to five minutes. Each reset instant fires once; everything else keeps the existing cadence.
|
||||
- **The popover's quota warning can be read in light mode, and says which limit it is warning about.** The banner under the tagline painted its text in the same system yellow, orange or red as its 12% pill, so in light mode warning text measured 1.2:1 to 1.4:1 against the pill, and even red reached only 3.0:1; in dark mode red fell to 3.3:1. The pill keeps its hue, and the text and its glyph now take a per-appearance colour for each severity, a deep amber, rust or brick in light mode and a lifted orange or red in dark mode, that measures 5.3:1 or better everywhere. A test holds every severity at WCAG AA (4.5:1) against the pill composited over both ends of the popover's background in both appearances. The sentence also named no window. Its figure is the provider's worst window, so "Claude 70% of quota used" meant the 5-hour limit while Claude's own panel read weekly 34%, and it looked wrong. It now reads `Claude · 5-hour 71% · resets in 3h 12m`, with the countdown worded exactly as the Capacity Dock words it and one line per provider when several are warning. The countdown is left off when the provider reports no reset time or the reset has already passed. "Over limit" is kept for a window at or past 100%, where it used to cover anything from 90%. The flame's colour and the providers that trigger the banner are unchanged.
|
||||
|
||||
|
|
|
|||
|
|
@ -84,7 +84,10 @@ enum SessionFocus {
|
|||
files: [ClaudeSessionFile],
|
||||
startTime: (pid_t) -> Date?
|
||||
) -> pid_t? {
|
||||
guard let file = files.first(where: { $0.sessionId == sessionID }),
|
||||
// Claude Code writes a fresh `<pid>.json` on every resume and never
|
||||
// removes the old one, so several files can name this session and only
|
||||
// the newest of them names the process that is still running.
|
||||
guard let file = files.filter({ $0.sessionId == sessionID }).max(by: { $0.startedAt < $1.startedAt }),
|
||||
let started = startTime(file.pid),
|
||||
abs(started.timeIntervalSince1970 - file.startedAt / 1000) < pidReuseTolerance
|
||||
else { return nil }
|
||||
|
|
|
|||
|
|
@ -1336,6 +1336,11 @@ struct CapacityDockDetailView: View {
|
|||
private struct SessionFocusAffordance: ViewModifier {
|
||||
let target: SessionFocusTarget?
|
||||
|
||||
/// A row can vanish while the pointer is still over it — the session leaves
|
||||
/// the ten-minute window, or the dock hides — and no hover exit ever fires,
|
||||
/// so the pushed cursor has to be popped on the way out instead.
|
||||
@State private var isHovering = false
|
||||
|
||||
@ViewBuilder
|
||||
func body(content: Content) -> some View {
|
||||
if let target {
|
||||
|
|
@ -1345,8 +1350,16 @@ private struct SessionFocusAffordance: ViewModifier {
|
|||
.accessibilityAddTraits(.isButton)
|
||||
.accessibilityHint(target.tooltip)
|
||||
.onHover { hovering in
|
||||
guard hovering != isHovering else { return }
|
||||
isHovering = hovering
|
||||
if hovering { NSCursor.pointingHand.push() } else { NSCursor.pop() }
|
||||
}
|
||||
.onDisappear {
|
||||
if isHovering {
|
||||
isHovering = false
|
||||
NSCursor.pop()
|
||||
}
|
||||
}
|
||||
.onTapGesture { SessionFocus.raise(target) }
|
||||
} else {
|
||||
content
|
||||
|
|
|
|||
|
|
@ -118,6 +118,31 @@ struct SessionFocusTests {
|
|||
#expect(pid == 222)
|
||||
}
|
||||
|
||||
@Test("a resumed session keeps the newest of the files claiming its id")
|
||||
func prefersNewestSessionFile() {
|
||||
let older = Date(timeIntervalSince1970: 1_780_000_000)
|
||||
let newer = older.addingTimeInterval(3_600)
|
||||
let files = [
|
||||
Self.file(pid: 111, id: "wanted", startedAt: older),
|
||||
Self.file(pid: 222, id: "wanted", startedAt: newer),
|
||||
]
|
||||
let pid = SessionFocus.pid(forSessionID: "wanted", files: files) { $0 == 111 ? older : newer }
|
||||
#expect(pid == 222)
|
||||
}
|
||||
|
||||
@Test("a newest file that fails the guard is not answered with the stale one")
|
||||
func refusesStaleFileBehindARecycledNewest() {
|
||||
let older = Date(timeIntervalSince1970: 1_780_000_000)
|
||||
let newer = older.addingTimeInterval(3_600)
|
||||
let files = [
|
||||
Self.file(pid: 111, id: "wanted", startedAt: older),
|
||||
Self.file(pid: 222, id: "wanted", startedAt: newer),
|
||||
]
|
||||
let recycled = newer.addingTimeInterval(SessionFocus.pidReuseTolerance + 1)
|
||||
let pid = SessionFocus.pid(forSessionID: "wanted", files: files) { $0 == 111 ? older : recycled }
|
||||
#expect(pid == nil)
|
||||
}
|
||||
|
||||
@Test("a pid the kernel no longer knows is not a session")
|
||||
func rejectsDeadPID() {
|
||||
let files = [Self.file(pid: 222, id: "wanted", startedAt: Date(timeIntervalSince1970: 1_780_000_000))]
|
||||
|
|
|
|||
|
|
@ -340,13 +340,24 @@ export async function collectKimicodeInputs(
|
|||
windowMs: number,
|
||||
roots: string[] = kimicodeHomes(),
|
||||
): Promise<LiveSessionInput[]> {
|
||||
// Walked level by level rather than recursively: an agent directory also
|
||||
// holds `blobs` and `file-history` trees, and descending into those turns a
|
||||
// two-thousand-file scan into an eighteen-thousand-entry one.
|
||||
const subdirectories = async (path: string): Promise<string[]> =>
|
||||
(await readdir(path, { withFileTypes: true }).catch(() => []))
|
||||
.filter(entry => entry.isDirectory())
|
||||
.map(entry => join(path, entry.name))
|
||||
|
||||
const paths: string[] = []
|
||||
for (const root of roots) {
|
||||
const entries = await readdir(join(root, 'sessions'), { recursive: true, withFileTypes: true }).catch(() => [])
|
||||
for (const entry of entries) {
|
||||
if (entry.isFile() && entry.name === 'wire.jsonl') paths.push(join(entry.parentPath, entry.name))
|
||||
}
|
||||
}
|
||||
await Promise.all(roots.map(async root => {
|
||||
await Promise.all((await subdirectories(join(root, 'sessions'))).map(async workDir => {
|
||||
await Promise.all((await subdirectories(workDir)).map(async sessionDir => {
|
||||
for (const agent of await subdirectories(join(sessionDir, 'agents'))) {
|
||||
paths.push(join(agent, 'wire.jsonl'))
|
||||
}
|
||||
}))
|
||||
}))
|
||||
}))
|
||||
|
||||
const bySession = new Map<string, { mainMs: number; subagentMs: number[] }>()
|
||||
await Promise.all(paths.map(async path => {
|
||||
|
|
|
|||
|
|
@ -262,6 +262,18 @@ describe('collectKimicodeInputs', () => {
|
|||
expect(inputs[0]!.startedMs).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('ignores a wire.jsonl buried in an agent blob or file-history tree', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'kimi-live-'))
|
||||
const dir = await session(root, 'wd_atlas_aaaaaaaaaaaa', 'session_decoys', JSON.stringify({ cwd: '/Users/x/atlas' }))
|
||||
await wire(dir, 'main', NOW - 20_000, [{ type: 'llm.request', model: 'k3' }])
|
||||
await wire(dir, join('main', 'blobs'), NOW - 1_000)
|
||||
await wire(dir, join('main', 'file-history', 'src'), NOW - 1_000)
|
||||
const inputs = await collectKimicodeInputs(NOW, WINDOW_MS, [root])
|
||||
expect(inputs.map(i => i.id)).toEqual(['decoys'])
|
||||
expect(inputs[0]!.subagentActivityMs).toEqual([])
|
||||
expect(inputs[0]!.lastActivityMs).toBe(NOW - 20_000)
|
||||
})
|
||||
|
||||
it('stays silent on a store that is not there', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'kimi-live-'))
|
||||
expect(await collectKimicodeInputs(NOW, WINDOW_MS, [join(root, 'missing')])).toEqual([])
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue