From 81f3c3a4bde32f47a7624e3ea911669f6c35dcc6 Mon Sep 17 00:00:00 2001 From: omartelo Date: Thu, 8 Oct 2026 11:25:55 -0300 Subject: [PATCH 1/3] feat: pick a turn a usage limit stopped back up once the limit resets Claude Code reports the turn through StopFailure (lich-plugin), and lich reads the limit and its reset off the transcript on every done. Codex reports nothing, so open Codex turns are read off their rollout every 30s and ended as interrupted. The continuation is a scheduled prompt due two minutes after the reset, within 24h, dropped by any turn that starts first; the card shows the limit on a rung of its own. --- CHANGELOG.md | 12 + README.md | 1 + README.zh-CN.md | 1 + docs/ceilings.md | 15 ++ docs/hooks/session-state.md | 16 +- .../src/components/sidebar/SessionCard.tsx | 15 +- .../components/sidebar/SessionLimitRung.tsx | 29 ++ frontend/src/lib/session/limit-line.test.ts | 43 +++ frontend/src/lib/session/limit-line.ts | 31 +++ frontend/src/lib/session/session-events.ts | 48 +++- .../lib/session/session-limit-store.test.ts | 87 ++++++ .../src/lib/session/session-limit-store.ts | 41 +++ frontend/src/lib/session/use-session-limit.ts | 17 ++ frontend/src/providers/project-events.tsx | 9 +- internal/relay/later.go | 31 ++- internal/relay/observe.go | 4 + internal/relay/relay.go | 8 +- internal/relay/resume.go | 112 ++++++++ internal/relay/resume_test.go | 120 +++++++++ internal/terminal/hookstate.go | 13 +- internal/terminal/limit.go | 251 ++++++++++++++++++ internal/terminal/limit_service_test.go | 115 ++++++++ internal/terminal/limit_test.go | 148 +++++++++++ internal/terminal/terminal.go | 13 +- main.go | 15 +- main_test.go | 6 + 26 files changed, 1166 insertions(+), 35 deletions(-) create mode 100644 frontend/src/components/sidebar/SessionLimitRung.tsx create mode 100644 frontend/src/lib/session/limit-line.test.ts create mode 100644 frontend/src/lib/session/limit-line.ts create mode 100644 frontend/src/lib/session/session-limit-store.test.ts create mode 100644 frontend/src/lib/session/session-limit-store.ts create mode 100644 frontend/src/lib/session/use-session-limit.ts create mode 100644 internal/relay/resume.go create mode 100644 internal/relay/resume_test.go create mode 100644 internal/terminal/limit.go create mode 100644 internal/terminal/limit_service_test.go create mode 100644 internal/terminal/limit_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 91a1d3ab7..dc1f8098b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 BMP, ICO and PDF up to 20 MB per side; the Pulls screen still says "Binary file". +- **A turn your plan's usage limit stopped picks up again once the limit + resets.** On Claude Code and Codex, the card says which limit stopped the + session and when it lifts, and lich types a prompt asking the agent to + continue two minutes after the reset. Any turn that starts before then, yours + or the provider's own, cancels it, and so does **Scheduled…** › **Cancel it** + in the card's menu. Nothing is parked for a limit that resets more than a day + out. Claude Code needs the lich-plugin release that reports `StopFailure`. + ### Changed - **The window is laid out as islands.** The session sidebar, the terminals and @@ -62,6 +70,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 worktree's long path no longer stretches the cards wider than the sidebar and pushes the rest of the list sideways while the drag is in flight. +- **A Codex card no longer spins forever after a usage limit ends its turn.** + Codex reports no end for that turn, so lich now reads it off the session's own + record. + ## [0.61.0] - 2026-10-07 ### Added diff --git a/README.md b/README.md index 6ee9f02a8..f17073186 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,7 @@ reads what each CLI writes down and no two of them write down the same things. | Context window in the footer | yes | yes | no | no | no | no | no | yes | | Cost in the footer | yes | yes | no | yes | yes | yes | no | credits | | How much of your plan is left | yes | yes | no | no | no | no | no | no | +| A turn your plan's usage limit stopped picks up again once the limit resets | yes | yes | no | its own retries | its own retries | no | no | no | | Spinner while a turn runs, ring when it ends | yes | yes | yes | yes | yes | no | no | yes | | Bell when the agent is blocked on you | yes | yes | no | yes | no | no | no | no | | Sidebar filter by state: Waiting, Running, Unread | yes | yes | no Waiting | yes | no Waiting | no, always Idle | no, always Idle | no Waiting | diff --git a/README.zh-CN.md b/README.zh-CN.md index c78c0b1a9..ce4ef7815 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -86,6 +86,7 @@ | 底栏里的上下文窗口 | 有 | 有 | 无 | 无 | 无 | 无 | 无 | 有 | | 底栏里的花费 | 有 | 有 | 无 | 有 | 有 | 有 | 无 | 按点数计 | | 套餐额度还剩多少 | 有 | 有 | 无 | 无 | 无 | 无 | 无 | 无 | +| 被套餐用量上限打断的一轮,在上限重置后自动接着做 | 有 | 有 | 无 | 由其自身重试 | 由其自身重试 | 无 | 无 | 无 | | 一轮进行中转圈,结束后成环 | 有 | 有 | 有 | 有 | 有 | 无 | 无 | 有 | | 智能体卡在等你时响铃 | 有 | 有 | 无 | 有 | 无 | 无 | 无 | 无 | | 侧栏按状态筛选:Waiting、Running、Unread | 有 | 有 | 无 Waiting | 有 | 无 Waiting | 无,总在 Idle | 无,总在 Idle | 无 Waiting | diff --git a/docs/ceilings.md b/docs/ceilings.md index e1eaaca2b..15d1a023c 100644 --- a/docs/ceilings.md +++ b/docs/ceilings.md @@ -580,6 +580,21 @@ work when nobody knows it and that the call site never shows. The mechanism and those logins and never writes them: it does not refresh the token, so an expired one reads as signed out until the provider's own CLI rotates it. A reading is cached for five minutes because both endpoints rate-limit hard — the number on screen is up to that old, and nothing on it says so. +- **A turn a usage limit stopped is picked back up on two providers only** (`internal/terminal/limit.go`, + `internal/relay/resume.go`): Claude Code and Codex are the two that write the limit down with its reset. On + Antigravity, Crush, Cursor CLI and Kiro CLI nothing marks a limit at all, so the card keeps spinning until the + next prompt; opencode and oh-my-pi retry by themselves and lich adds nothing on top. Claude Code's end arrives + only as `StopFailure`, which an older lich-plugin does not report. Codex raises no hook for it, so an open + Codex turn is read off its rollout every 30 seconds, and the limit is *inferred*: a turn completing with + nothing said right after a window shows 100% spent (measured on `codex exec` 0.144.5, never on the TUI) — a + change to that shape stops the read without a word. The continuation is a scheduled prompt told apart from a + person's by its exact text, so someone parking those same words has them dropped on the session's next turn. + Nothing is parked for a reset already past or more than 24 hours out (Claude Code's own auto-continue gives up + at the same horizon), nor over a prompt already parked there. Any turn starting before it is due drops it, + which is also what keeps it from landing behind Claude Code's own `autoContinueAtUsageLimit` — a setting the + server enables per account, which is why the prompt is due two minutes after the reset rather than at it. The + card's limit rung lives in the window alone: a reload drops it, while the parked prompt stays on the card as a + plain scheduled one. - **The pace marker is borrowed calibration, and it is silent far more often than it is wrong** (`internal/quota/pace.go`): the two numbers that decide when a weekly window is marked as spending ahead — fifteen percentage points past the elapsed share, and no marking at all in the first twenty-four hours after diff --git a/docs/hooks/session-state.md b/docs/hooks/session-state.md index 0104364eb..416f39444 100644 --- a/docs/hooks/session-state.md +++ b/docs/hooks/session-state.md @@ -48,7 +48,7 @@ Both sides test against the payloads in | `PreToolUse` | `PreToolUse` | `PreToolUse` | `tool.execute.before` | `tool_call` | — | dropped | `preToolUse` | `busy` + `tool` | | `PostToolUse` | `PostToolUse` | — | `tool.execute.after` | `turn_start` | — | dropped | `postToolUse` | `busy` | | `Notification` | `PermissionRequest` | — | any `*.asked` | — | — | — | — | `waiting` + `reason` | -| `Stop` | `Stop` | `Stop` | `session.status` (`idle`) | `session_stop` | — | — | `stop` | `done` | +| `Stop`, `StopFailure` | `Stop` | `Stop` | `session.status` (`idle`) | `session_stop` | — | — | `stop` | `done` | | `SessionEnd` | — | — | — | — | — | `SessionEnd` | — | `idle` | **Kiro closes four of the six rows and neither of the other two.** It has no @@ -69,6 +69,20 @@ same payload. Its `idle` means the turn ended, which is lich's `done` — not lich's `idle`, which says the CLI itself has left. Nothing in opencode's event list says that, so like Codex it never reports `idle`. +**Claude Code ends a turn an API error stopped with `StopFailure`, not `Stop`.** +It fires instead of `Stop` when a usage limit, an overload or a failed login ends +the turn (2.1.293), and a client that registers only `Stop` leaves the card +spinning until the next prompt. It reports `done` like `Stop`: the turn is over +either way. Which error it was is not in the report — lich reads it off the +transcript on every `done`, and a usage limit it finds there parks the turn's +continuation for when the limit resets (docs/ceilings.md). + +**Codex reports no end at all for a turn a usage limit stopped**: measured on +0.144.5, `UserPromptSubmit` fires and `Stop` never does. lich ends that turn +itself, reading the rollout of every open Codex turn, and publishes it as +`interrupted` — the state it already raises for a turn that ended without the +provider saying so. + **oh-my-pi carries `busy` on `turn_start` rather than after a tool.** Its non-interactive runs never emit `input` at all, so the turn boundary is the one event every turn passes through — the same place the title is re-read, since omp diff --git a/frontend/src/components/sidebar/SessionCard.tsx b/frontend/src/components/sidebar/SessionCard.tsx index 40a178735..fa8410227 100644 --- a/frontend/src/components/sidebar/SessionCard.tsx +++ b/frontend/src/components/sidebar/SessionCard.tsx @@ -44,6 +44,7 @@ import { useSessionCwd } from "@/lib/session/use-session-cwd" import { useSessionAgent } from "@/lib/session/use-session-agent" import { useSessionRelay } from "@/lib/session/use-session-relay" import { useSessionInbox } from "@/lib/session/use-session-inbox" +import { useSessionLimit } from "@/lib/session/use-session-limit" import { useSessionTool } from "@/lib/session/use-session-tool" import { useSessionTodo } from "@/lib/session/use-session-todo" import { useHandoffHeld } from "@/lib/terminal/handoff-store" @@ -88,6 +89,7 @@ import { timeUntil } from "@/lib/session/schedule" import { SessionTargetPicker } from "./SessionTargetPicker" import { EntrypointDialog } from "./EntrypointDialog" import { SchedulePromptDialog } from "./SchedulePromptDialog" +import { SessionLimitRung } from "./SessionLimitRung" interface SessionCardProps { session: Session @@ -247,6 +249,7 @@ export function SessionCard({ // How many results this session has waiting in the relay's inbox: results of // tasks it delegated, uncollected. Zero — the usual case — draws nothing. const inbox = useSessionInbox(session.id) + const limit = useSessionLimit(session.id) // How far the agent got through the task list it wrote for itself. null for // most sessions: no list, a finished one, or a provider whose list lich // cannot read (terminal.todoReaderFor). @@ -512,9 +515,9 @@ export function SessionCard({ )} - {/* One line, eight rungs: an open request, then a session blocked - on the user, then a handoff waiting for this prompt, then - results waiting to be collected, then how far + {/* One line, nine rungs: an open request, then a session blocked + on the user, then a handoff waiting for this prompt, then a + usage limit the turn stopped on, then results waiting to be collected, then how far a quiet card got through its task list, then the tool, then a prompt scheduled for later, then where the session came from. A request in flight @@ -526,7 +529,9 @@ export function SessionCard({ session wants an answer. A held handoff sits under the block and over the rest: text was handed to this session and is not at its prompt, which without a rung is indistinguishable from a - click that did nothing. The inbox sits under those and over + click that did nothing. A usage limit sits there too, and for + the same reason: the card is stopped on something, and only + the rung says what and until when. The inbox sits under those and over the tool: mid-turn the live tool is the news, and the count takes the rung when the card goes quiet — the same rule the relay's own nudge follows. Task-list progress answers to that @@ -578,6 +583,8 @@ export function SessionCard({ Something is waiting for this prompt + ) : limit ? ( + ) : status !== "busy" && inbox > 0 ? ( diff --git a/frontend/src/components/sidebar/SessionLimitRung.tsx b/frontend/src/components/sidebar/SessionLimitRung.tsx new file mode 100644 index 000000000..387c1ff13 --- /dev/null +++ b/frontend/src/components/sidebar/SessionLimitRung.tsx @@ -0,0 +1,29 @@ +import { Hourglass } from "lucide-react" +import { limitLine } from "@/lib/session/limit-line" +import type { SessionLimit } from "@/lib/session/session-events" + +// The card's rung for a turn a usage limit ended: amber like the block above it, +// because the session is stopped on something it cannot get past by itself. +export function SessionLimitRung({ + limit, + scheduledAt, +}: { + limit: SessionLimit + scheduledAt: number +}) { + const line = limitLine(limit, scheduledAt, new Date()) + return ( + + + + {line.name} + {line.next && ( + <> + {` · ${line.next} `} + {line.when} + + )} + + + ) +} diff --git a/frontend/src/lib/session/limit-line.test.ts b/frontend/src/lib/session/limit-line.test.ts new file mode 100644 index 000000000..0d7378fe0 --- /dev/null +++ b/frontend/src/lib/session/limit-line.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest" +import { limitLine } from "./limit-line" +import { scheduledFor } from "./schedule" + +const now = new Date(2026, 9, 8, 18, 0, 0) +const seconds = Math.floor(now.getTime() / 1000) +const HOUR = 3600 + +describe("limitLine", () => { + it("counts down to the continuation parked after the reset", () => { + const resetsAt = seconds + 2 * HOUR + expect(limitLine({ window: "session", resetsAt }, resetsAt + 120, now)).toEqual({ + name: "Session limit", + next: "resumes", + when: "in 2h", + }) + }) + + it("says when it resets when nothing will resume it", () => { + const resetsAt = seconds + 3 * 24 * HOUR + expect(limitLine({ window: "weekly", resetsAt }, 0, now)).toEqual({ + name: "Weekly limit", + next: "resets", + when: scheduledFor(resetsAt, now), + }) + }) + + // A prompt the person parked before the reset is theirs, not the + // continuation: it lands into a limit that still holds. + it("does not call an earlier prompt a resume", () => { + const resetsAt = seconds + 2 * HOUR + expect(limitLine({ window: "", resetsAt }, seconds + HOUR, now).next).toBe("resets") + }) + + it("names only the limit once its reset is past or unknown", () => { + expect(limitLine({ window: "", resetsAt: seconds - 60 }, 0, now)).toEqual({ + name: "Usage limit", + next: "", + when: "", + }) + expect(limitLine({ window: "session", resetsAt: 0 }, seconds + HOUR, now).next).toBe("") + }) +}) diff --git a/frontend/src/lib/session/limit-line.ts b/frontend/src/lib/session/limit-line.ts new file mode 100644 index 000000000..940374a03 --- /dev/null +++ b/frontend/src/lib/session/limit-line.ts @@ -0,0 +1,31 @@ +import { scheduledFor, timeUntil } from "./schedule" +import type { SessionLimit } from "./session-events" + +const WINDOW_NAMES: Record = { + session: "Session limit", + weekly: "Weekly limit", + "": "Usage limit", +} + +// What the card says about a usage limit: the window's name, then what happens +// next. A prompt parked at or after the reset is the continuation (lich parks +// it there, internal/relay resume.go), so the card counts down to it; without +// one it says when the limit lifts, which is the moment a person can go on by +// hand. `when` is empty for a reset already behind us or one never reported. +export interface LimitLine { + name: string + next: "resumes" | "resets" | "" + when: string +} + +export function limitLine(limit: SessionLimit, scheduledAt: number, now: Date): LimitLine { + const name = WINDOW_NAMES[limit.window] + const resumesIn = scheduledAt >= limit.resetsAt ? timeUntil(scheduledAt, now) : null + if (limit.resetsAt > 0 && resumesIn) { + return { name, next: "resumes", when: resumesIn } + } + if (limit.resetsAt * 1000 > now.getTime()) { + return { name, next: "resets", when: scheduledFor(limit.resetsAt, now) } + } + return { name, next: "", when: "" } +} diff --git a/frontend/src/lib/session/session-events.ts b/frontend/src/lib/session/session-events.ts index baa698606..75679f813 100644 --- a/frontend/src/lib/session/session-events.ts +++ b/frontend/src/lib/session/session-events.ts @@ -83,13 +83,20 @@ export const MCP_EVENT = "session-mcp" // to draw the cost without a ring (docs/ceilings.md). export const USAGE_EVENT = "session-usage" -// Global event the backend emits when a session's scheduled prompt has been -// typed at it (see relay.ScheduleEventName). Payload: { id, at } — at is 0, -// because the prompt has just been delivered and nothing is waiting anymore. -// The window is the only other writer of that row and already knows what it -// wrote; this is what the clock did. +// Global event the backend emits when a session's scheduled prompt changed +// without the window writing it (see relay.ScheduleEventName): typed at its +// session, or parked and dropped by lich around a usage limit. Payload: +// { id, at, prompt? } — at is 0 and prompt absent when nothing is waiting +// anymore. export const SCHEDULE_EVENT = "session-schedule" +// Global event the backend emits when a plan's usage limit ended a session's +// turn (see terminal.limitEventName). Payload: { id, window, resetsAt } — +// window is "session", "weekly" or "" for one the card has no name for, and +// resetsAt is unix seconds, 0 when the provider did not say. Nothing clears it +// but the session's next turn (session-limit-store). +export const LIMIT_EVENT = "session-limit" + // Global event the backend emits when a scheduled prompt was lost with the // session it was parked on — deleted, forgotten, purged with its worktree, or // taken by a deleted project (see store.ScheduleForfeitEventName). Payload: @@ -299,8 +306,35 @@ export function isIdEvent(data: unknown): data is { id: string } { ) } -export function isScheduleEvent(data: unknown): data is { id: string; at: number } { - return isIdEvent(data) && typeof (data as { at?: unknown }).at === "number" +export function isScheduleEvent( + data: unknown, +): data is { id: string; at: number; prompt?: string } { + if (!isIdEvent(data) || typeof (data as { at?: unknown }).at !== "number") { + return false + } + const prompt = (data as { prompt?: unknown }).prompt + return prompt === undefined || typeof prompt === "string" +} + +const LIMIT_WINDOWS = ["session", "weekly", ""] as const + +// The usage limit a session's last turn ended on: which window ran out, and +// when it resets in unix seconds (0 when the provider did not say). +export interface SessionLimit { + window: (typeof LIMIT_WINDOWS)[number] + resetsAt: number +} + +// toSessionLimit narrows a limit payload, or null for one this build cannot +// read. A window from a newer backend reads as the unnamed one: the limit is +// still a limit, only its name is new. +export function toSessionLimit(data: unknown): SessionLimit | null { + const { window, resetsAt } = (data ?? {}) as { window?: unknown; resetsAt?: unknown } + if (typeof resetsAt !== "number") { + return null + } + const named = (LIMIT_WINDOWS as readonly unknown[]).includes(window) + return { window: named ? (window as SessionLimit["window"]) : "", resetsAt } } // session-schedule-forfeited names a session that is already gone, so the label diff --git a/frontend/src/lib/session/session-limit-store.test.ts b/frontend/src/lib/session/session-limit-store.test.ts new file mode 100644 index 000000000..3158f4d81 --- /dev/null +++ b/frontend/src/lib/session/session-limit-store.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest" +import { isScheduleEvent, toSessionLimit } from "./session-events" +import { createSessionLimitStore } from "./session-limit-store" + +function source() { + const handlers: Array<(data: unknown) => void> = [] + return { + subscribe: (handler: (data: unknown) => void) => { + handlers.push(handler) + return () => {} + }, + emit: (data: unknown) => { + for (const handler of handlers) { + handler(data) + } + }, + } +} + +function build() { + const limit = source() + const status = source() + const store = createSessionLimitStore(limit.subscribe, status.subscribe) + return { limit, status, store } +} + +describe("toSessionLimit", () => { + it("reads the window and the reset", () => { + expect(toSessionLimit({ id: "s1", window: "weekly", resetsAt: 1700 })).toEqual({ + window: "weekly", + resetsAt: 1700, + }) + }) + + it("keeps a limit whose window this build has no name for", () => { + expect(toSessionLimit({ id: "s1", window: "monthly", resetsAt: 1700 })).toEqual({ + window: "", + resetsAt: 1700, + }) + }) + + it("refuses a payload with no reset", () => { + expect(toSessionLimit({ id: "s1", window: "session" })).toBeNull() + expect(toSessionLimit(null)).toBeNull() + }) +}) + +describe("isScheduleEvent", () => { + it("accepts the delivery's clear and lich's own park", () => { + expect(isScheduleEvent({ id: "s1", at: 0 })).toBe(true) + expect(isScheduleEvent({ id: "s1", at: 1700, prompt: "go on" })).toBe(true) + }) + + it("refuses a prompt that is not text", () => { + expect(isScheduleEvent({ id: "s1", at: 1700, prompt: 3 })).toBe(false) + }) +}) + +describe("createSessionLimitStore", () => { + it("holds the limit a turn ended on", () => { + const { limit, store } = build() + limit.emit({ id: "s1", window: "session", resetsAt: 1700 }) + expect(store.get("s1")).toEqual({ window: "session", resetsAt: 1700 }) + expect(store.get("s2")).toBeNull() + }) + + // Codex's end of turn reaches the window after the limit, Claude's before it: + // neither may take the limit down. + it("keeps it through the turn's own end", () => { + const { limit, status, store } = build() + limit.emit({ id: "s1", window: "session", resetsAt: 1700 }) + status.emit({ id: "s1", state: "interrupted" }) + status.emit({ id: "s1", state: "done" }) + expect(store.get("s1")).not.toBeNull() + }) + + it("clears when a turn starts or the session ends", () => { + const { limit, status, store } = build() + limit.emit({ id: "s1", window: "session", resetsAt: 1700 }) + status.emit({ id: "s1", state: "busy" }) + expect(store.get("s1")).toBeNull() + + limit.emit({ id: "s1", window: "session", resetsAt: 1700 }) + status.emit({ id: "s1", state: "idle" }) + expect(store.get("s1")).toBeNull() + }) +}) diff --git a/frontend/src/lib/session/session-limit-store.ts b/frontend/src/lib/session/session-limit-store.ts new file mode 100644 index 000000000..223c17909 --- /dev/null +++ b/frontend/src/lib/session/session-limit-store.ts @@ -0,0 +1,41 @@ +import { createKeyedStore, type ReadableKeyedStore } from "@/lib/keyed-store" +import { + isIdEvent, + isStatusEvent, + type SessionEventSource, + type SessionLimit, + toSessionLimit, +} from "./session-events" + +function sameLimit(a: SessionLimit | null, b: SessionLimit | null): boolean { + if (a === null || b === null) { + return a === b + } + return a.window === b.window && a.resetsAt === b.resetsAt +} + +// createSessionLimitStore keeps the usage limit each session's last turn ended +// on, keyed by session id. A turn starting ("busy") is the proof the limit no +// longer holds, and the CLI leaving ("idle") takes the session with it; nothing +// else clears it, because the turn's own end can reach the window on either +// side of the limit (internal/terminal, limit.go). +export function createSessionLimitStore( + limitSource: SessionEventSource, + statusSource: SessionEventSource, +): ReadableKeyedStore { + const store = createKeyedStore(null, sameLimit) + + limitSource((data) => { + if (isIdEvent(data)) { + store.set(data.id, toSessionLimit(data)) + } + }) + + statusSource((data) => { + if (isStatusEvent(data) && (data.state === "busy" || data.state === "idle")) { + store.set(data.id, null) + } + }) + + return store +} diff --git a/frontend/src/lib/session/use-session-limit.ts b/frontend/src/lib/session/use-session-limit.ts new file mode 100644 index 000000000..91688ef04 --- /dev/null +++ b/frontend/src/lib/session/use-session-limit.ts @@ -0,0 +1,17 @@ +import { onAppEvent } from "@/lib/app-events" +import { useKeyedStore } from "@/lib/use-keyed-store" +import { LIMIT_EVENT, STATUS_EVENT, type SessionLimit } from "./session-events" +import { createSessionLimitStore } from "./session-limit-store" + +// Subscribed at import, like the relay store: a limit reported while the card +// is unmounted still has to be there when it mounts. +const store = createSessionLimitStore( + (handler) => onAppEvent(LIMIT_EVENT, handler), + (handler) => onAppEvent(STATUS_EVENT, handler), +) + +// useSessionLimit reads the usage limit a session's last turn ended on, or null +// while none holds. +export function useSessionLimit(id: string): SessionLimit | null { + return useKeyedStore(store, id) +} diff --git a/frontend/src/providers/project-events.tsx b/frontend/src/providers/project-events.tsx index dade2e7fb..c157445ce 100644 --- a/frontend/src/providers/project-events.tsx +++ b/frontend/src/providers/project-events.tsx @@ -165,15 +165,16 @@ export function useSessionEvents({ return () => off() }, []) - // A scheduled prompt has been typed at its session, so the mark comes off the - // card at that moment rather than at the next reload. The row is already - // cleared on the backend's side of it, so this never writes back. + // A scheduled prompt changed on the backend's side — typed at its session, + // or parked and dropped around a usage limit — so the mark follows at that + // moment rather than at the next reload. The row is already written there, + // so this never writes back. useEffect(() => { const off = onAppEvent(SCHEDULE_EVENT, (data) => { if (!isScheduleEvent(data)) { return } - const next = setSessionSchedule(sessionsRef.current, data.id, data.at, "") + const next = setSessionSchedule(sessionsRef.current, data.id, data.at, data.prompt ?? "") if (next !== sessionsRef.current) { commit(next) } diff --git a/internal/relay/later.go b/internal/relay/later.go index d07228510..e64ffe2f8 100644 --- a/internal/relay/later.go +++ b/internal/relay/later.go @@ -24,21 +24,20 @@ import ( // that can be asked for, and it costs one read of the open sessions. const scheduleTick = 30 * time.Second -// ScheduleEventName is emitted when a session's scheduled prompt is delivered, -// so the card drops its mark at the moment the prompt is typed rather than at -// the next reload. Payload: ScheduleEvent. The window is the only other writer -// of that row, and it already knows what it wrote — this event says what the -// clock did. +// ScheduleEventName is emitted when a session's scheduled prompt changes +// without the window having written it: delivered, so the card drops its mark +// at the moment the prompt is typed rather than at the next reload, or parked +// and dropped by lich itself around a usage limit (resume.go). Payload: +// ScheduleEvent. const ScheduleEventName = "session-schedule" // ScheduleEvent is the payload of ScheduleEventName: the session whose mark -// changed, and when its prompt is now due. At is 0 for every event this package -// emits — the prompt has just been typed and nothing is waiting — and is in the -// payload anyway because the mark is a time, and an event that only ever means -// "clear" would have to be replaced the first time anything else moves one. +// changed, when its prompt is now due, and the prompt. At is 0 and Prompt empty +// when nothing is waiting anymore. type ScheduleEvent struct { - ID string `json:"id"` - At int64 `json:"at"` + ID string `json:"id"` + At int64 `json:"at"` + Prompt string `json:"prompt,omitempty"` } // RunSchedules types due prompts at their sessions until the process ends. @@ -74,7 +73,15 @@ func (s *Service) deliverDue() { now := s.now().Unix() for _, p := range projects { for _, sess := range p.Sessions { - if sess.ScheduledPrompt == "" || sess.ScheduledAt == 0 || sess.ScheduledAt > now { + if sess.ScheduledPrompt == "" || sess.ScheduledAt == 0 { + continue + } + // Learned here as well as when parked: a continuation parked before + // a restart is still lich's to drop when its session moves on. + if sess.ScheduledPrompt == resumePrompt { + s.markResume(sess.ID) + } + if sess.ScheduledAt > now { continue } if !s.takesDelivery(sess.ID) { diff --git a/internal/relay/observe.go b/internal/relay/observe.go index 4cc70b622..4a3eb3377 100644 --- a/internal/relay/observe.go +++ b/internal/relay/observe.go @@ -22,6 +22,7 @@ func (s *Service) Observe(sessionID, state string) { s.mu.Lock() blocked := s.noteBlockLocked(sessionID, state) finished := s.finishedWorkerLocked(sessionID, state) + resumed := s.takeResumeLocked(sessionID, state) s.recordState(sessionID, state) ended, notice := s.endedErrands(sessionID, state) // A waiter still holding the line carries the news out through its own @@ -60,6 +61,9 @@ func (s *Service) Observe(sessionID, state string) { if finished { go s.finishWorker(sessionID) } + if resumed { + go s.dropResume(sessionID) + } // This session as a sender: its turn ending frees its prompt, which is what // a nudge held back during the turn was waiting for. A turn stopped with Esc // or Ctrl-C frees it just the same, and is how a wait for results is usually diff --git a/internal/relay/relay.go b/internal/relay/relay.go index 0803c87e1..87c22cce8 100644 --- a/internal/relay/relay.go +++ b/internal/relay/relay.go @@ -185,8 +185,8 @@ type RelayEvent struct { type Sessions interface { LoadState() ([]store.Project, error) // SetSessionSchedule clears a scheduled prompt once it has been typed at its - // session (see deliverDue). Only ever called to clear here: the window is - // what parks one. + // session (see deliverDue), and parks or drops the continuation of a turn a + // usage limit ended (resume.go). Every other prompt is parked by the window. SetSessionSchedule(sessionID string, at int64, prompt string) error // SessionBranch is the branch of the checkout a session runs in, "" when // git cannot name one. A subagent's report names it (subagentReport). @@ -460,6 +460,9 @@ type Service struct { // reportedWorkers is the sessions that answered a subagent errand in the // turn running now; the turn ending finishes them (finishedWorkerLocked). reportedWorkers map[string]bool + // resumes is the sessions holding a continuation lich parked after a usage + // limit (resume.go), so a turn starting there can drop it. + resumes map[string]bool // workerFinished is told about a worker that finished. Nil leaves every // worker running, the state a test that does not care is in. workerFinished func(workerID string) error @@ -489,6 +492,7 @@ func New(sessions Sessions, term Terminal, events Events) *Service { held: make(map[string]*inboxEntry), lapsed: make(map[string]*ticket), reportedWorkers: make(map[string]bool), + resumes: make(map[string]bool), collectors: make(map[string][]chan struct{}), nudgeTimer: make(map[string]*time.Timer), nudging: make(map[string]*sync.Mutex), diff --git a/internal/relay/resume.go b/internal/relay/resume.go new file mode 100644 index 000000000..becef3026 --- /dev/null +++ b/internal/relay/resume.go @@ -0,0 +1,112 @@ +package relay + +import ( + "log/slog" + "time" + + "github.com/omartelo/lich/internal/store" +) + +// A turn a plan's usage limit ended is picked up again once the limit resets: +// lich parks a scheduled prompt for that moment (later.go), the same row a +// person parks one on, so it shows on the card, survives a restart and is typed +// the way every scheduled prompt is. The terminal service finds the limit and +// its reset (internal/terminal, limit.go); this file decides whether to park. + +// resumePrompt is what lich types at a session whose usage limit has reset. It +// is also how a parked prompt is told apart as lich's: the row has no column +// saying who wrote it. +// ponytail: matched by text, a person who parks these exact words has them +// dropped on the session's next turn; a column on the row if that ever matters. +const resumePrompt = "[lich] Your usage limit reset. Continue the task you were working on." + +// resumeGrace is how long after the reset the prompt is due. Claude Code's own +// autoContinueAtUsageLimit, where the server enables it, resumes at the reset +// itself; the grace lets it go first, and its turn starting drops this prompt +// (dropResume) instead of a second continuation landing behind it. +const resumeGrace = 2 * time.Minute + +// resumeHorizon is how far out a reset may be and still be waited for. The same +// horizon Claude Code's own auto-continue gives up at (2.1.293: "the usage limit +// now resets more than 24 hours out"): picking a task back up days later, on its +// own, surprises more than it helps, so the card only says when it resets. +const resumeHorizon = 24 * time.Hour + +// ParkResume parks the continuation of session id's turn, due resumeGrace after +// resetsAt (unix seconds). Nothing is parked for a reset already past or beyond +// resumeHorizon, nor over a prompt already parked there: there is one per +// session, and a person's own outranks lich's. +func (s *Service) ParkResume(id string, resetsAt int64) { + now := s.now() + reset := time.Unix(resetsAt, 0) + if !reset.After(now) || reset.Sub(now) > resumeHorizon { + return + } + sess, ok := s.sessionRow(id) + if !ok || sess.ScheduledPrompt != "" { + return + } + due := reset.Add(resumeGrace).Unix() + if err := s.sessions.SetSessionSchedule(id, due, resumePrompt); err != nil { + slog.Warn("relay: park resume", "session", sess.Label, "err", err) + return + } + s.markResume(id) + if s.events != nil { + s.events.Emit(ScheduleEventName, ScheduleEvent{ID: id, At: due, Prompt: resumePrompt}) + } +} + +// dropResume clears session id's parked continuation, which a turn starting +// there has made moot: the person went on by hand, the provider resumed on its +// own, or something else was delivered. A prompt the person parked in its +// place since is theirs and stays. +func (s *Service) dropResume(id string) { + sess, ok := s.sessionRow(id) + if !ok || sess.ScheduledPrompt != resumePrompt { + return + } + if err := s.sessions.SetSessionSchedule(id, 0, ""); err != nil { + slog.Warn("relay: drop resume", "session", sess.Label, "err", err) + return + } + if s.events != nil { + s.events.Emit(ScheduleEventName, ScheduleEvent{ID: id}) + } +} + +// markResume records that session id holds a continuation lich parked, so the +// turn that makes it moot can drop it without reading the workspace on every +// busy report (see Observe). +func (s *Service) markResume(id string) { + s.mu.Lock() + defer s.mu.Unlock() + s.resumes[id] = true +} + +// takeResumeLocked answers whether a report of state ends session id's parked +// continuation, and forgets it if so. Called under s.mu. +func (s *Service) takeResumeLocked(id, state string) bool { + if state != stateBusy || !s.resumes[id] { + return false + } + delete(s.resumes, id) + return true +} + +// sessionRow finds session id in the workspace. +func (s *Service) sessionRow(id string) (store.Session, bool) { + projects, err := s.sessions.LoadState() + if err != nil { + slog.Warn("relay: read session", "session", id, "err", err) + return store.Session{}, false + } + for _, p := range projects { + for _, sess := range p.Sessions { + if sess.ID == id { + return sess, true + } + } + } + return store.Session{}, false +} diff --git a/internal/relay/resume_test.go b/internal/relay/resume_test.go new file mode 100644 index 000000000..b6d1c6dd3 --- /dev/null +++ b/internal/relay/resume_test.go @@ -0,0 +1,120 @@ +package relay + +import ( + "sync" + "testing" + "time" +) + +// parkWriter records every scheduled-prompt write, parks included. +type parkWriter struct { + mu sync.Mutex + writes []ScheduleEvent +} + +func (w *parkWriter) set(sessionID string, at int64, prompt string) error { + w.mu.Lock() + defer w.mu.Unlock() + w.writes = append(w.writes, ScheduleEvent{ID: sessionID, At: at, Prompt: prompt}) + return nil +} + +func (w *parkWriter) all() []ScheduleEvent { + w.mu.Lock() + defer w.mu.Unlock() + return append([]ScheduleEvent(nil), w.writes...) +} + +func TestParkResumeParksTheContinuationAfterTheReset(t *testing.T) { + writer := &parkWriter{} + events := &fakeEvents{} + svc := newRelay(scheduledWith(0, "", writer.set), newFakeTerminal("s1"), events) + svc.now = at(1000) + + svc.ParkResume("s1", 1000+3600) + + want := ScheduleEvent{ID: "s1", At: 1000 + 3600 + int64(resumeGrace/time.Second), Prompt: resumePrompt} + if got := writer.all(); len(got) != 1 || got[0] != want { + t.Fatalf("writes = %+v, want [%+v]", got, want) + } + if got := events.schedules(); len(got) != 1 || got[0] != want { + t.Fatalf("schedule events = %+v, want [%+v]", got, want) + } +} + +func TestParkResumeParksNothingItShouldNotWaitFor(t *testing.T) { + tests := []struct { + name string + resetsAt int64 + prompt string + }{ + {"a reset already past", 999, ""}, + {"a reset beyond the horizon", 1000 + int64(resumeHorizon/time.Second) + 1, ""}, + {"a prompt the person parked", 1000 + 3600, "ship the release notes"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + writer := &parkWriter{} + svc := newRelay(scheduledWith(5000, tc.prompt, writer.set), newFakeTerminal("s1"), &fakeEvents{}) + svc.now = at(1000) + + svc.ParkResume("s1", tc.resetsAt) + + if got := writer.all(); len(got) != 0 { + t.Fatalf("writes = %+v, want none", got) + } + }) + } +} + +// A turn starting before the reset makes the continuation moot — the person +// went on by hand, or Claude Code resumed on its own — and it is dropped rather +// than typed behind that turn. Learned from the row too, so a continuation +// parked before a restart is dropped all the same. +func TestATurnStartingDropsTheParkedContinuation(t *testing.T) { + writer := &parkWriter{} + events := &fakeEvents{} + svc := newRelay(scheduledWith(5000, resumePrompt, writer.set), newFakeTerminal("s1"), events) + svc.now = at(1000) + svc.deliverDue() + + svc.Observe("s1", stateBusy) + + waitUntil(t, func() bool { return len(writer.all()) == 1 }) + if got := writer.all()[0]; got != (ScheduleEvent{ID: "s1"}) { + t.Fatalf("write = %+v, want the row cleared", got) + } + if got := events.schedules(); len(got) != 1 || got[0] != (ScheduleEvent{ID: "s1"}) { + t.Fatalf("schedule events = %+v, want one clearing s1", got) + } +} + +func TestATurnStartingLeavesThePersonsPromptAlone(t *testing.T) { + writer := &parkWriter{} + svc := newRelay(scheduledWith(5000, "ship the release notes", writer.set), newFakeTerminal("s1"), &fakeEvents{}) + svc.now = at(1000) + svc.markResume("s1") + + svc.dropResume("s1") + + if got := writer.all(); len(got) != 0 { + t.Fatalf("writes = %+v, want the person's prompt kept", got) + } +} + +func scheduledWith(at int64, prompt string, set func(string, int64, string) error) fakeSessions { + sessions := scheduled(at, prompt, &scheduleWriter{}) + sessions.schedule = set + return sessions +} + +func waitUntil(t *testing.T, cond func() bool) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for !cond() { + if time.Now().After(deadline) { + t.Fatal("condition never held") + } + time.Sleep(time.Millisecond) + } +} diff --git a/internal/terminal/hookstate.go b/internal/terminal/hookstate.go index ca716e08d..5447d8bac 100644 --- a/internal/terminal/hookstate.go +++ b/internal/terminal/hookstate.go @@ -1,6 +1,10 @@ package terminal -import "sync" +import ( + "maps" + "slices" + "sync" +) // turnLog is the memory behind the one distinction the session-state contract // cannot make on its own (docs/hooks/session-state.md): Claude Code raises the @@ -98,3 +102,10 @@ func (l *turnLog) interrupt(id string) bool { delete(l.open, id) return true } + +// openIDs is every session with a turn open right now, in no order. +func (l *turnLog) openIDs() []string { + l.mu.Lock() + defer l.mu.Unlock() + return slices.Collect(maps.Keys(l.open)) +} diff --git a/internal/terminal/limit.go b/internal/terminal/limit.go new file mode 100644 index 000000000..23821ffd0 --- /dev/null +++ b/internal/terminal/limit.go @@ -0,0 +1,251 @@ +package terminal + +import ( + "bytes" + "encoding/json" + "log/slog" + "slices" + "time" + + "github.com/omartelo/lich/internal/providers" +) + +// limitEventName carries a turn a plan's usage limit ended ({id, window, +// resetsAt}). Nothing clears it: the next state report the session makes does, +// on the window's side (session-limit-store.ts), because any turn starting is +// the proof the limit no longer holds. +const limitEventName = "session-limit" + +// limitWatchTick is how often an open Codex turn is checked for having ended on +// a usage limit. Codex raises no hook when that happens (docs/hooks/session-state.md), +// so this read is the only way the card stops spinning; the scheduled prompt +// polls at the same pace, and nothing here needs to be finer than it. +const limitWatchTick = 30 * time.Second + +// The windows a usage limit names, in the window's vocabulary rather than any +// provider's. Anything else a provider reports (a spend cap, a model-scoped +// window) reads as plainly "usage limit" on the card. +const ( + limitWindowSession = "session" + limitWindowWeekly = "weekly" +) + +// usageLimit is a turn a provider ended because the plan's usage limit was hit: +// which window ran out, and when it resets in unix seconds (0 when the provider +// did not say). +type usageLimit struct { + window string + resetsAt int64 +} + +// limitEvent is the payload of limitEventName. +type limitEvent struct { + ID string `json:"id"` + Window string `json:"window"` + ResetsAt int64 `json:"resetsAt"` +} + +// usageLimitFor reads whether a conversation's last turn ended on a usage limit. +// Only Claude Code and Codex record one together with its reset; every other +// provider retries on its own or records nothing a reader can trust +// (docs/ceilings.md), and reads as no limit. +func usageLimitFor(src usageSource) (usageLimit, bool) { + switch src.kind { + case providers.Claude: + tail, ok := readTail(src.path, usageTailBytes) + if !ok { + return usageLimit{}, false + } + return parseClaudeUsageLimit(tail) + case providers.Codex: + return scanCodexUsageLimit(src.path) + } + return usageLimit{}, false +} + +// claudeLimitWindows maps Claude Code's rateLimitType onto the card's windows. +var claudeLimitWindows = map[string]string{ + "five_hour": limitWindowSession, + "seven_day": limitWindowWeekly, + "seven_day_opus": limitWindowWeekly, + "seven_day_sonnet": limitWindowWeekly, +} + +// parseClaudeUsageLimit reads the newest main-thread assistant line of a Claude +// transcript tail. When a limit ends a turn, Claude Code writes a synthetic +// assistant line with error "rate_limit" and the rejection it got, reset +// included, under quotaLimits (measured on 2.1.293). Any other newest line +// means the conversation went on past whatever limit came before it. +func parseClaudeUsageLimit(tail []byte) (usageLimit, bool) { + for _, line := range slices.Backward(bytes.Split(tail, []byte("\n"))) { + var entry struct { + Type string `json:"type"` + IsSidechain bool `json:"isSidechain"` + Error string `json:"error"` + QuotaLimits *struct { + ResetsAt int64 `json:"resetsAt"` + RateLimitType string `json:"rateLimitType"` + } `json:"quotaLimits"` + } + if json.Unmarshal(bytes.TrimSpace(line), &entry) != nil { + continue + } + if entry.Type != "assistant" || entry.IsSidechain { + continue + } + if entry.Error != "rate_limit" || entry.QuotaLimits == nil { + return usageLimit{}, false + } + return usageLimit{ + window: claudeLimitWindows[entry.QuotaLimits.RateLimitType], + resetsAt: entry.QuotaLimits.ResetsAt, + }, true + } + return usageLimit{}, false +} + +// codexRateWindow is one window of a rollout's rate_limits block. +type codexRateWindow struct { + UsedPercent float64 `json:"used_percent"` + WindowMinutes int `json:"window_minutes"` + ResetsAt int64 `json:"resets_at"` +} + +// The window lengths Codex reports for its two plan windows. +const ( + codexSessionMinutes = 5 * 60 + codexWeeklyMinutes = 7 * 24 * 60 +) + +// scanCodexUsageLimit reads whether a Codex rollout's last turn ended on a usage +// limit. Codex writes no error for it: the turn completes with no agent message, +// right after a token_count whose rate_limits show a window spent to 100% — +// measured on 0.144.5 against a server answering usage_limit_reached. A turn +// that completed with something said, or one still running, is no limit. +func scanCodexUsageLimit(path string) (usageLimit, bool) { + var limit usageLimit + completed, found := false, false + codexReverseScan(path, func(line []byte) bool { + var entry struct { + Type string `json:"type"` + Payload struct { + Type string `json:"type"` + LastAgentMessage *string `json:"last_agent_message"` + RateLimits *struct { + Primary *codexRateWindow `json:"primary"` + Secondary *codexRateWindow `json:"secondary"` + } `json:"rate_limits"` + } `json:"payload"` + } + if json.Unmarshal(line, &entry) != nil || entry.Type != "event_msg" { + return false + } + switch entry.Payload.Type { + case "task_complete": + if completed { + return true + } + completed = entry.Payload.LastAgentMessage == nil + return !completed + case "token_count": + if !completed { + return true + } + if entry.Payload.RateLimits == nil { + return false + } + limit, found = spentCodexWindow(entry.Payload.RateLimits.Primary, entry.Payload.RateLimits.Secondary) + return true + case "task_started", "user_message": + return true + } + return false + }) + return limit, found +} + +// spentCodexWindow is the window a token_count shows spent, the later reset +// when both are: the turn can only continue once the last of them lifts. +func spentCodexWindow(windows ...*codexRateWindow) (usageLimit, bool) { + var limit usageLimit + found := false + for _, w := range windows { + if w == nil || w.UsedPercent < 100 || w.ResetsAt <= limit.resetsAt { + continue + } + limit, found = usageLimit{window: codexLimitWindow(w.WindowMinutes), resetsAt: w.ResetsAt}, true + } + return limit, found +} + +func codexLimitWindow(minutes int) string { + switch minutes { + case codexSessionMinutes: + return limitWindowSession + case codexWeeklyMinutes: + return limitWindowWeekly + } + return "" +} + +// noteLimit reads whether session id's last turn ended on a usage limit and, +// when it did, tells the window and hands the reset to whoever parks the +// continuation (SetUsageLimit). Silent on every miss: no limit is the usual +// answer, and a transcript lich cannot read is one it has nothing to say about. +func (s *Service) noteLimit(id string) bool { + src, ok := s.transcriptSource(id) + if !ok { + return false + } + limit, ok := usageLimitFor(src) + if !ok { + return false + } + s.hub.Emit(limitEventName, limitEvent{ID: id, Window: limit.window, ResetsAt: limit.resetsAt}) + if park := s.limitWatcher(); park != nil && limit.resetsAt > 0 { + park(id, limit.resetsAt) + } + return true +} + +// SetUsageLimit wires fn to every turn a usage limit ended that names its reset +// (relay.ParkResume). Only one watcher, like SetSessionState. +func (s *Service) SetUsageLimit(fn func(id string, resetsAt int64)) { + s.mu.Lock() + defer s.mu.Unlock() + s.onLimit = fn +} + +func (s *Service) limitWatcher() func(id string, resetsAt int64) { + s.mu.Lock() + defer s.mu.Unlock() + return s.onLimit +} + +// RunLimitWatch checks every open Codex turn for having ended on a usage limit, +// until the process ends. Started once at launch: with no Codex turn open, a +// pass costs one look at the turn log. +func (s *Service) RunLimitWatch() { + ticker := time.NewTicker(limitWatchTick) + defer ticker.Stop() + for range ticker.C { + s.watchCodexLimits() + } +} + +// watchCodexLimits ends each open Codex turn whose rollout shows it stopped on a +// usage limit. It ends it the way an interrupted one is ended — a limit is not a +// finished turn either, and the provider will never report the end itself. +func (s *Service) watchCodexLimits() { + for _, id := range s.turns.openIDs() { + src, ok := s.transcriptSource(id) + if !ok || src.kind != providers.Codex { + continue + } + if !s.noteLimit(id) { + continue + } + slog.Info("terminal: codex turn ended on a usage limit", "session", id) + s.noteInterrupt(id) + } +} diff --git a/internal/terminal/limit_service_test.go b/internal/terminal/limit_service_test.go new file mode 100644 index 000000000..bb2c52b3d --- /dev/null +++ b/internal/terminal/limit_service_test.go @@ -0,0 +1,115 @@ +// The suite reuses stubBins from terminal_test.go, which is Unix-tagged for its +// PTY spawns; this file carries the same tag so the package still builds on +// Windows. Nothing here is platform-specific. +//go:build !windows + +package terminal + +import ( + "strings" + "sync" + "testing" +) + +// parkRecorder stands in for the relay's ParkResume. +type parkRecorder struct { + mu sync.Mutex + parks map[string]int64 +} + +func (p *parkRecorder) park(id string, resetsAt int64) { + p.mu.Lock() + defer p.mu.Unlock() + if p.parks == nil { + p.parks = make(map[string]int64) + } + p.parks[id] = resetsAt +} + +func (p *parkRecorder) of(id string) (int64, bool) { + p.mu.Lock() + defer p.mu.Unlock() + at, ok := p.parks[id] + return at, ok +} + +// Claude Code reports the turn a limit ended as `done` (StopFailure, through the +// plugin); the transcript says it was a limit and when it lifts. +func TestADoneOnAClaudeLimitTellsTheWindowAndParksTheResume(t *testing.T) { + writeTranscript(t, "uuid-abc", claudeLimitLine+"\n") + hub, rec := newProbeHub(t) + svc := New(stubBins{providerSession: "uuid-abc"}, nil, hub) + parks := &parkRecorder{} + svc.SetUsageLimit(parks.park) + + svc.onHookState(hookRequest{SessionID: "s1", State: statusBusy}) + svc.onHookState(hookRequest{SessionID: "s1", State: statusDone}) + + waitFor(t, func() bool { _, ok := parks.of("s1"); return ok }, "the resume to be parked") + if at, _ := parks.of("s1"); at != 1788653400 { + t.Errorf("parked for %d, want the transcript's reset 1788653400", at) + } + waitFor(t, func() bool { _, ok := rec.payloadOf(limitEventName); return ok }, "the limit event") + payload, _ := rec.payloadOf(limitEventName) + got, _ := payload.(map[string]any) + if got["id"] != "s1" || got["window"] != limitWindowSession || got["resetsAt"] != float64(1788653400) { + t.Errorf("limit event = %v", payload) + } +} + +func TestATurnThatFinishedParksNothing(t *testing.T) { + writeTranscript(t, "uuid-abc", `{"type":"assistant","message":{"content":[{"type":"text","text":"All green."}]}}`+"\n") + svc := New(stubBins{providerSession: "uuid-abc"}, nil, nil) + parks := &parkRecorder{} + svc.SetUsageLimit(parks.park) + + if svc.noteLimit("s1") { + t.Fatal("a turn that ended with an answer read as a limit") + } + if _, ok := parks.of("s1"); ok { + t.Fatal("a resume was parked for a turn that finished") + } +} + +// Codex raises no hook when a limit ends its turn, so the watch is what ends it: +// the card stops spinning and the resume is parked all the same. +func TestTheWatchEndsACodexTurnALimitStopped(t *testing.T) { + plantCodexRollout(t, "codex-1", strings.Join([]string{ + codexTaskStarted, codexUserMessage, codexLimitTokens, codexSilentFinish, + }, "\n")+"\n") + hub, rec := newProbeHub(t) + svc := New(stubBins{providerSession: "codex-1"}, nil, hub) + parks := &parkRecorder{} + svc.SetUsageLimit(parks.park) + svc.turns.report("s1", statusBusy) + + svc.watchCodexLimits() + + if svc.turns.busy("s1") { + t.Error("the turn a limit ended is still open") + } + if at, ok := parks.of("s1"); !ok || at != 1791432079 { + t.Errorf("parked %d, %v; want the rollout's reset 1791432079", at, ok) + } + waitFor(t, func() bool { return len(rec.statesOf("s1")) > 0 }, "the turn's end to reach the window") + if got := rec.statesOf("s1"); got[len(got)-1] != statusInterrupted { + t.Errorf("window was told %v, want the turn interrupted", got) + } +} + +// Only an open turn is looked at: a Codex session sitting at its prompt has +// nothing a limit could still be ending. +func TestTheWatchLeavesAClosedTurnAlone(t *testing.T) { + plantCodexRollout(t, "codex-1", strings.Join([]string{ + codexTaskStarted, codexLimitTokens, codexSilentFinish, + }, "\n")+"\n") + svc := New(stubBins{providerSession: "codex-1"}, nil, nil) + parks := &parkRecorder{} + svc.SetUsageLimit(parks.park) + + svc.watchCodexLimits() + + if _, ok := parks.of("s1"); ok { + t.Fatal("a session with no open turn had a resume parked") + } +} diff --git a/internal/terminal/limit_test.go b/internal/terminal/limit_test.go new file mode 100644 index 000000000..6798babd5 --- /dev/null +++ b/internal/terminal/limit_test.go @@ -0,0 +1,148 @@ +package terminal + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// claudeLimitLine is the synthetic assistant line Claude Code 2.1.293 wrote when +// a session limit ended a turn, trimmed to the fields that matter from a real +// transcript. +const claudeLimitLine = `{"type":"assistant","isSidechain":false,"message":{"model":"","role":"assistant","content":[{"type":"text","text":"You've hit your session limit · resets 9:10pm (America/Sao_Paulo)"}]},"quotaLimits":{"status":"rejected","resetsAt":1788653400,"rateLimitType":"five_hour","overageStatus":"rejected"},"error":"rate_limit","isApiErrorMessage":true,"apiErrorStatus":429}` + +func TestClaudeUsageLimitIsReadOffTheLastAssistantLine(t *testing.T) { + tests := []struct { + name string + lines []string + want usageLimit + found bool + }{ + { + name: "a session limit ends the turn", + lines: []string{`{"type":"user","message":{"content":"go on"}}`, claudeLimitLine}, + want: usageLimit{window: limitWindowSession, resetsAt: 1788653400}, + found: true, + }, + { + name: "a weekly limit", + lines: []string{strings.Replace(claudeLimitLine, `"five_hour"`, `"seven_day"`, 1)}, + want: usageLimit{window: limitWindowWeekly, resetsAt: 1788653400}, + found: true, + }, + { + name: "a window the card has no name for", + lines: []string{strings.Replace(claudeLimitLine, `"five_hour"`, `"overage"`, 1)}, + want: usageLimit{resetsAt: 1788653400}, + found: true, + }, + { + name: "the conversation went on after the limit", + lines: []string{ + claudeLimitLine, + `{"type":"user","message":{"content":"continue"}}`, + `{"type":"assistant","message":{"content":[{"type":"text","text":"Picking up."}]}}`, + }, + }, + { + name: "a sub-agent's line after the limit is not the session's", + lines: []string{ + claudeLimitLine, + `{"type":"assistant","isSidechain":true,"message":{"content":[{"type":"text","text":"sub"}]}}`, + }, + want: usageLimit{window: limitWindowSession, resetsAt: 1788653400}, + found: true, + }, + { + name: "another API error is no limit", + lines: []string{strings.Replace(claudeLimitLine, `"error":"rate_limit"`, `"error":"overloaded"`, 1)}, + }, + { + name: "no assistant line at all", + lines: []string{`{"type":"user","message":{"content":"hi"}}`}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, found := parseClaudeUsageLimit([]byte(strings.Join(tc.lines, "\n") + "\n")) + if found != tc.found || got != tc.want { + t.Fatalf("parseClaudeUsageLimit = %+v, %v; want %+v, %v", got, found, tc.want, tc.found) + } + }) + } +} + +// The rollout lines a Codex turn wrote when its server answered +// usage_limit_reached (0.144.5, measured against a fake responses endpoint), +// with the payloads cut to the fields the reader looks at. +const ( + codexTaskStarted = `{"type":"event_msg","payload":{"type":"task_started","turn_id":"t1"}}` + codexUserMessage = `{"type":"event_msg","payload":{"type":"user_message","message":"say hi"}}` + codexLimitTokens = `{"type":"event_msg","payload":{"type":"token_count","info":null,"rate_limits":{"limit_id":"codex","primary":{"used_percent":100.0,"window_minutes":300,"resets_at":1791432079},"secondary":null}}}` + codexSilentFinish = `{"type":"event_msg","payload":{"type":"task_complete","turn_id":"t1","last_agent_message":null}}` + codexSpokenFinish = `{"type":"event_msg","payload":{"type":"task_complete","turn_id":"t1","last_agent_message":"Done."}}` + codexHealthyTokens = `{"type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"total_tokens":10}},"rate_limits":{"primary":{"used_percent":26.0,"window_minutes":10080,"resets_at":1787629462}}}}` +) + +func TestCodexUsageLimitIsReadOffTheTurnsEnd(t *testing.T) { + weeklyToo := strings.Replace(codexLimitTokens, `"secondary":null`, + `"secondary":{"used_percent":100.0,"window_minutes":10080,"resets_at":1791900000}`, 1) + tests := []struct { + name string + lines []string + want usageLimit + found bool + }{ + { + name: "the measured turn", + lines: []string{codexTaskStarted, codexUserMessage, codexLimitTokens, codexSilentFinish}, + want: usageLimit{window: limitWindowSession, resetsAt: 1791432079}, + found: true, + }, + { + name: "both windows spent wait for the later reset", + lines: []string{codexTaskStarted, weeklyToo, codexSilentFinish}, + want: usageLimit{window: limitWindowWeekly, resetsAt: 1791900000}, + found: true, + }, + { + name: "a turn that said something finished", + lines: []string{codexTaskStarted, codexLimitTokens, codexSpokenFinish}, + }, + { + name: "a silent finish with room left in the plan", + lines: []string{codexTaskStarted, codexHealthyTokens, codexSilentFinish}, + }, + { + name: "a turn still running", + lines: []string{codexTaskStarted, codexLimitTokens}, + }, + { + name: "a silent finish with no token count in its turn", + lines: []string{codexLimitTokens, codexSilentFinish, codexTaskStarted, codexSilentFinish}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "rollout.jsonl") + if err := os.WriteFile(path, []byte(strings.Join(tc.lines, "\n")+"\n"), 0o644); err != nil { + t.Fatal(err) + } + got, found := scanCodexUsageLimit(path) + if found != tc.found || got != tc.want { + t.Fatalf("scanCodexUsageLimit = %+v, %v; want %+v, %v", got, found, tc.want, tc.found) + } + }) + } +} + +func TestTurnLogNamesEveryOpenTurn(t *testing.T) { + var l turnLog + l.report("a", statusBusy) + l.report("b", statusBusy) + l.report("b", statusDone) + if got := l.openIDs(); len(got) != 1 || got[0] != "a" { + t.Fatalf("openIDs = %v, want [a]", got) + } +} diff --git a/internal/terminal/terminal.go b/internal/terminal/terminal.go index 9d4baee4d..4dea72b50 100644 --- a/internal/terminal/terminal.go +++ b/internal/terminal/terminal.go @@ -283,6 +283,9 @@ type Service struct { // its process dies: the relay ends a closed worker's errand there, ahead of // the SessionEnd the dying CLI reports. Guarded by mu like onState. onClosed func(id string) + // onLimit, when set, is told about every turn a usage limit ended that names + // its reset (limit.go). Guarded by mu like onState. + onLimit func(id string, resetsAt int64) // spawns is how each live session's PTY was started — what it runs and where // — keyed by session id. Read by providerKind, which is what stops a // session-start report from repainting a card lich itself chose the provider @@ -518,6 +521,10 @@ func (s *Service) onHookState(req hookRequest) { // and the read is off-thread for the same reason the usage one is. if req.State == statusDone { go s.applyKiroTitle(req.SessionID) + // Claude Code ends a turn a usage limit stopped with StopFailure, which + // the plugin reports as `done`; whether it was a limit is in the + // transcript, off-thread like every other read of it. + go s.noteLimit(req.SessionID) } } @@ -622,8 +629,10 @@ func (s *Service) MountPublic(pattern string, handler http.Handler) { // and turnLog.interrupt). It is the fallback for the three providers that raise // no event of their own when a turn is interrupted — Claude Code, Codex and // oh-my-pi all skip the hook that would end it, so without this the card spins -// until some later turn finishes. It publishes "interrupted" rather than "done" -// because stopping a turn is not finishing one, and it never opens a turn, so +// until some later turn finishes. A Codex turn a usage limit ended is closed +// here too (watchCodexLimits): Codex skips that hook for it as well. It +// publishes "interrupted" rather than "done" because stopping a turn is not +// finishing one, and it never opens a turn, so // nothing is invented for a session lich never heard start. Whatever the // provider reports next overwrites it: an event from inside the session always // outranks a guess made from its keystrokes. diff --git a/main.go b/main.go index 34d746469..5d34288ca 100644 --- a/main.go +++ b/main.go @@ -288,6 +288,11 @@ func registerServices(db *store.Service, term *terminal.Service, hub *events.Hub // The scheduled prompts are the relay's other clock: nobody calls in for // them, they come due. go rl.RunSchedules() + // A turn a usage limit ended is picked up again once it resets, through + // the same scheduled prompt. Codex reports no end for that turn, so the + // terminal watches its open turns for one. + term.SetUsageLimit(rl.ParkResume) + go term.RunLimitWatch() dispatcher.Register("relay", rl) // Its caller is not the window either: opening a session for an agent starts // the PTY here rather than waiting for someone to click the card. @@ -332,13 +337,16 @@ func registerServices(db *store.Service, term *terminal.Service, hub *events.Hub // - relay.RunSchedules is the scheduled-prompt loop, started once at launch // and never returning: called over /rpc/ it holds that request open for the // life of the process and starts a second loop racing the first for every -// due prompt. +// due prompt. terminal.RunLimitWatch is the same kind of loop, for the +// Codex turns a usage limit ended. +// - relay.ParkResume parks a prompt that types itself at a session; the +// terminal calls it for a turn a usage limit ended, with the reset it read. // - spawn.CloseFinishedWorker closes a session with none of spawn.Close's // checks; the relay calls it for a worker that reported back. // - relay.SetPlugins, relay.SetWorkerFinished, project.SetAccounts, project.SetProjects, // quota.SetSessions, store.SetSessionGone, store.SetScheduleForfeited, // store.SetBranchOf, store.SetTranscriptOf, terminal.SetDropDir, -// terminal.SetRateLimitReports, terminal.SetWorkerAnswer and +// terminal.SetRateLimitReports, terminal.SetUsageLimit, terminal.SetWorkerAnswer and // terminal.SetSessionClosed are startup wiring. Called with [null] they silently // nil what they wired (encoding/json leaves a func or pointer alone on // null), and the write races the readers already serving — nilling @@ -369,6 +377,7 @@ func denyInternal(d *rpc.Handler) { "relay.WorkerAnswered", "relay.SessionClosed", "relay.RunSchedules", + "relay.ParkResume", "agentplugin.RepairRegistrations", "relay.SetPlugins", "relay.SetWorkerFinished", @@ -380,6 +389,8 @@ func denyInternal(d *rpc.Handler) { "terminal.SessionAccount", "terminal.SetDropDir", "terminal.SetRateLimitReports", + "terminal.SetUsageLimit", + "terminal.RunLimitWatch", "terminal.SetWorkerAnswer", "terminal.SetSessionClosed", "terminal.EnqueueModCommand", diff --git a/main_test.go b/main_test.go index ed4bb9c4b..86ed8d7f3 100644 --- a/main_test.go +++ b/main_test.go @@ -30,11 +30,14 @@ var denied = map[string]reflect.Type{ "drop.SetPicker": reflect.TypeFor[*drop.Service](), "relay.Observe": reflect.TypeFor[*relay.Service](), "relay.RunSchedules": reflect.TypeFor[*relay.Service](), + "relay.ParkResume": reflect.TypeFor[*relay.Service](), "agentplugin.RepairRegistrations": reflect.TypeFor[*agentplugin.Service](), "relay.SetPlugins": reflect.TypeFor[*relay.Service](), "project.SetAccounts": reflect.TypeFor[*project.Service](), "project.SetProjects": reflect.TypeFor[*project.Service](), "terminal.SetDropDir": reflect.TypeFor[*terminal.Service](), + "terminal.SetUsageLimit": reflect.TypeFor[*terminal.Service](), + "terminal.RunLimitWatch": reflect.TypeFor[*terminal.Service](), "terminal.EnqueueModCommand": reflect.TypeFor[*terminal.Service](), "terminal.RunModCommand": reflect.TypeFor[*terminal.Service](), "terminal.SubmitPrompt": reflect.TypeFor[*terminal.Service](), @@ -53,6 +56,9 @@ func (stubService) Purge() error { return nil } func (stubService) SetPicker() error { return nil } func (stubService) Observe() error { return nil } func (stubService) RunSchedules() error { return nil } +func (stubService) ParkResume() error { return nil } +func (stubService) SetUsageLimit() error { return nil } +func (stubService) RunLimitWatch() error { return nil } func (stubService) RepairRegistrations() error { return nil } func (stubService) SetPlugins() error { return nil } func (stubService) SetAccounts() error { return nil } From db02cc204240526540fdb060d0fc23af273c4517 Mon Sep 17 00:00:00 2001 From: omartelo Date: Thu, 8 Oct 2026 11:51:46 -0300 Subject: [PATCH 2/3] docs: rephrase the usage-limit comments without dashes --- docs/ceilings.md | 4 ++-- docs/hooks/session-state.md | 4 ++-- frontend/src/lib/session/session-events.ts | 4 ++-- frontend/src/providers/project-events.tsx | 4 ++-- internal/relay/resume_test.go | 4 ++-- internal/terminal/limit.go | 4 ++-- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/ceilings.md b/docs/ceilings.md index 15d1a023c..e14b62ef1 100644 --- a/docs/ceilings.md +++ b/docs/ceilings.md @@ -586,12 +586,12 @@ work when nobody knows it and that the call site never shows. The mechanism and next prompt; opencode and oh-my-pi retry by themselves and lich adds nothing on top. Claude Code's end arrives only as `StopFailure`, which an older lich-plugin does not report. Codex raises no hook for it, so an open Codex turn is read off its rollout every 30 seconds, and the limit is *inferred*: a turn completing with - nothing said right after a window shows 100% spent (measured on `codex exec` 0.144.5, never on the TUI) — a + nothing said right after a window shows 100% spent (measured on `codex exec` 0.144.5, never on the TUI); a change to that shape stops the read without a word. The continuation is a scheduled prompt told apart from a person's by its exact text, so someone parking those same words has them dropped on the session's next turn. Nothing is parked for a reset already past or more than 24 hours out (Claude Code's own auto-continue gives up at the same horizon), nor over a prompt already parked there. Any turn starting before it is due drops it, - which is also what keeps it from landing behind Claude Code's own `autoContinueAtUsageLimit` — a setting the + which is also what keeps it from landing behind Claude Code's own `autoContinueAtUsageLimit`, a setting the server enables per account, which is why the prompt is due two minutes after the reset rather than at it. The card's limit rung lives in the window alone: a reload drops it, while the parked prompt stays on the card as a plain scheduled one. diff --git a/docs/hooks/session-state.md b/docs/hooks/session-state.md index 416f39444..2c205c357 100644 --- a/docs/hooks/session-state.md +++ b/docs/hooks/session-state.md @@ -73,14 +73,14 @@ list says that, so like Codex it never reports `idle`. It fires instead of `Stop` when a usage limit, an overload or a failed login ends the turn (2.1.293), and a client that registers only `Stop` leaves the card spinning until the next prompt. It reports `done` like `Stop`: the turn is over -either way. Which error it was is not in the report — lich reads it off the +either way. Which error it was is not in the report: lich reads it off the transcript on every `done`, and a usage limit it finds there parks the turn's continuation for when the limit resets (docs/ceilings.md). **Codex reports no end at all for a turn a usage limit stopped**: measured on 0.144.5, `UserPromptSubmit` fires and `Stop` never does. lich ends that turn itself, reading the rollout of every open Codex turn, and publishes it as -`interrupted` — the state it already raises for a turn that ended without the +`interrupted`, the state it already raises for a turn that ended without the provider saying so. **oh-my-pi carries `busy` on `turn_start` rather than after a tool.** Its diff --git a/frontend/src/lib/session/session-events.ts b/frontend/src/lib/session/session-events.ts index 75679f813..87ec66195 100644 --- a/frontend/src/lib/session/session-events.ts +++ b/frontend/src/lib/session/session-events.ts @@ -86,12 +86,12 @@ export const USAGE_EVENT = "session-usage" // Global event the backend emits when a session's scheduled prompt changed // without the window writing it (see relay.ScheduleEventName): typed at its // session, or parked and dropped by lich around a usage limit. Payload: -// { id, at, prompt? } — at is 0 and prompt absent when nothing is waiting +// { id, at, prompt? }; at is 0 and prompt absent when nothing is waiting // anymore. export const SCHEDULE_EVENT = "session-schedule" // Global event the backend emits when a plan's usage limit ended a session's -// turn (see terminal.limitEventName). Payload: { id, window, resetsAt } — +// turn (see terminal.limitEventName). Payload: { id, window, resetsAt }. // window is "session", "weekly" or "" for one the card has no name for, and // resetsAt is unix seconds, 0 when the provider did not say. Nothing clears it // but the session's next turn (session-limit-store). diff --git a/frontend/src/providers/project-events.tsx b/frontend/src/providers/project-events.tsx index c157445ce..872022971 100644 --- a/frontend/src/providers/project-events.tsx +++ b/frontend/src/providers/project-events.tsx @@ -165,8 +165,8 @@ export function useSessionEvents({ return () => off() }, []) - // A scheduled prompt changed on the backend's side — typed at its session, - // or parked and dropped around a usage limit — so the mark follows at that + // A scheduled prompt changed on the backend's side (typed at its session, + // or parked and dropped around a usage limit), so the mark follows at that // moment rather than at the next reload. The row is already written there, // so this never writes back. useEffect(() => { diff --git a/internal/relay/resume_test.go b/internal/relay/resume_test.go index b6d1c6dd3..2db8ff6f7 100644 --- a/internal/relay/resume_test.go +++ b/internal/relay/resume_test.go @@ -67,8 +67,8 @@ func TestParkResumeParksNothingItShouldNotWaitFor(t *testing.T) { } } -// A turn starting before the reset makes the continuation moot — the person -// went on by hand, or Claude Code resumed on its own — and it is dropped rather +// A turn starting before the reset makes the continuation moot (the person +// went on by hand, or Claude Code resumed on its own), and it is dropped rather // than typed behind that turn. Learned from the row too, so a continuation // parked before a restart is dropped all the same. func TestATurnStartingDropsTheParkedContinuation(t *testing.T) { diff --git a/internal/terminal/limit.go b/internal/terminal/limit.go index 23821ffd0..4efa4b7e7 100644 --- a/internal/terminal/limit.go +++ b/internal/terminal/limit.go @@ -119,7 +119,7 @@ const ( // scanCodexUsageLimit reads whether a Codex rollout's last turn ended on a usage // limit. Codex writes no error for it: the turn completes with no agent message, -// right after a token_count whose rate_limits show a window spent to 100% — +// right after a token_count whose rate_limits show a window spent to 100%, // measured on 0.144.5 against a server answering usage_limit_reached. A turn // that completed with something said, or one still running, is no limit. func scanCodexUsageLimit(path string) (usageLimit, bool) { @@ -234,7 +234,7 @@ func (s *Service) RunLimitWatch() { } // watchCodexLimits ends each open Codex turn whose rollout shows it stopped on a -// usage limit. It ends it the way an interrupted one is ended — a limit is not a +// usage limit. It ends it the way an interrupted one is ended: a limit is not a // finished turn either, and the provider will never report the end itself. func (s *Service) watchCodexLimits() { for _, id := range s.turns.openIDs() { From ef49639f3bb84c15746f7429853a5d6ee665f3ee Mon Sep 17 00:00:00 2001 From: omartelo Date: Thu, 8 Oct 2026 12:15:43 -0300 Subject: [PATCH 3/3] refactor: move the card's status rung into a component of its own SessionCard was past the 800-line ceiling, and the usage-limit rung added one more branch to the ladder. The ladder and the stores only it reads move to SessionStatusRung; the card keeps one line. render-budget changes because the contract did: the rung is now a child the card repaints with (CARD_CHROME), and a todo event repaints the rung alone instead of the whole card. --- .../src/components/render-budget.test.tsx | 13 +- .../src/components/sidebar/SessionCard.tsx | 188 +--------------- .../components/sidebar/SessionStatusRung.tsx | 206 ++++++++++++++++++ 3 files changed, 221 insertions(+), 186 deletions(-) create mode 100644 frontend/src/components/sidebar/SessionStatusRung.tsx diff --git a/frontend/src/components/render-budget.test.tsx b/frontend/src/components/render-budget.test.tsx index 21b08edc4..a4128360f 100644 --- a/frontend/src/components/render-budget.test.tsx +++ b/frontend/src/components/render-budget.test.tsx @@ -238,10 +238,11 @@ async function mountSidebar() { return budget } -// What one card drags along when it repaints: its own chrome, and the base-ui -// context-menu and tooltip parts wrapped around it. The unnamed pair are -// base-ui's own anonymous render functions. +// What one card drags along when it repaints: its own chrome, its status line, +// and the base-ui context-menu and tooltip parts wrapped around it. The unnamed +// pair are base-ui's own anonymous render functions. const CARD_CHROME = { + SessionStatusRung: 1, ContextMenu: 1, ContextMenuRoot: 1, ContextMenuTrigger: 2, @@ -285,10 +286,12 @@ test("a finished turn repaints that session's card and no other block", async () await budget.unmount() }) -test("a todo event repaints that session's card and nothing else", async () => { +// The task list is read by the status line alone, so its count moving repaints +// that line and leaves the card around it, and its menus and tooltip, alone. +test("a todo event repaints that session's status line and nothing else", async () => { const budget = await mountSidebar() await budget.act(() => bus.emit(TODO_EVENT, { id: "s2", done: 3, total: 7 })) - expect(budget.take()).toEqual({ "SessionCard#s2": 1, ...CARD_CHROME }) + expect(budget.take()).toEqual({ SessionStatusRung: 1 }) await budget.unmount() }) diff --git a/frontend/src/components/sidebar/SessionCard.tsx b/frontend/src/components/sidebar/SessionCard.tsx index fa8410227..e7fdf9990 100644 --- a/frontend/src/components/sidebar/SessionCard.tsx +++ b/frontend/src/components/sidebar/SessionCard.tsx @@ -2,21 +2,15 @@ import { useEffect, useRef, useState } from "react" import type { CSSProperties, KeyboardEvent } from "react" import { ArrowDown, - ArrowLeft, ArrowRight, - CircleQuestionMark, Clock, Copy, - CornerDownLeft, Folder, FolderCode, FolderMinus, FolderOpen, GitBranch, GitPullRequestArrow, - Hourglass, - Inbox, - ListChecks, Pencil, Columns2, Pin, @@ -38,18 +32,9 @@ import { useSessionStatus, useSessionStatusAge, useSessionUnread, - useSessionWaitingReason, } from "@/lib/session/use-session-status" import { useSessionCwd } from "@/lib/session/use-session-cwd" import { useSessionAgent } from "@/lib/session/use-session-agent" -import { useSessionRelay } from "@/lib/session/use-session-relay" -import { useSessionInbox } from "@/lib/session/use-session-inbox" -import { useSessionLimit } from "@/lib/session/use-session-limit" -import { useSessionTool } from "@/lib/session/use-session-tool" -import { useSessionTodo } from "@/lib/session/use-session-todo" -import { useHandoffHeld } from "@/lib/terminal/handoff-store" -import { toolGlyph } from "@/lib/session/tool-glyph" -import { toolLine } from "@/lib/session/tool-label" import { useGitStatus } from "@/lib/git/use-git-status" import { baseReadout } from "@/lib/git/base-status" import { usePullRequest } from "@/lib/pulls/use-pull-request" @@ -89,7 +74,7 @@ import { timeUntil } from "@/lib/session/schedule" import { SessionTargetPicker } from "./SessionTargetPicker" import { EntrypointDialog } from "./EntrypointDialog" import { SchedulePromptDialog } from "./SchedulePromptDialog" -import { SessionLimitRung } from "./SessionLimitRung" +import { SessionStatusRung } from "./SessionStatusRung" interface SessionCardProps { session: Session @@ -219,10 +204,6 @@ export function SessionCard({ // the bells all look alike and the one blocked longest is the one to answer // first. "" for the states that have no clock (see useSessionStatusAge). const age = useSessionStatusAge(session.id) - // What the session is blocked on, when its provider's event had words for it: - // the line the user reads to decide whether this is the card to open. "" from - // a provider that reports the block and nothing about it. - const waitingReason = useSessionWaitingReason(session.id) // The provider CLI live inside the PTY right now — a hand-run `claude` or // `codex` in a shell session puts that provider's mark on the card while it // runs; null falls back to the session's own kind. @@ -233,32 +214,6 @@ export function SessionCard({ const rung = useSandboxRung(session.kind, projectId) const confined = session.sandboxed ?? false const drift = rung === null ? "" : sandboxDrift(rung, !!session.path, confined) - // The tool the turn is running right now, reported by the provider's pre-tool - // hook: null outside a tool call, which is what keeps the card its usual size - // whenever nothing is happening in it. - const tool = useSessionTool(session.id) - // The request this session has open with another, reported by the relay when - // a message lands in a PTY and cleared when it is answered. null the rest of - // the time, which is nearly always. - const relay = useSessionRelay(session.id) - // Text handed to this session — a pull request's conflict, the issue a - // worktree was named after — still waiting for a prompt free enough to take - // it. The wait can be minutes, and until it ends the card looks exactly like - // one nothing was handed to (write-at-prompt.ts). - const handoffHeld = useHandoffHeld(session.id) - // How many results this session has waiting in the relay's inbox: results of - // tasks it delegated, uncollected. Zero — the usual case — draws nothing. - const inbox = useSessionInbox(session.id) - const limit = useSessionLimit(session.id) - // How far the agent got through the task list it wrote for itself. null for - // most sessions: no list, a finished one, or a provider whose list lich - // cannot read (terminal.todoReaderFor). - const todo = useSessionTodo(session.id) - const ToolGlyph = tool && toolGlyph(tool.name) - // The two halves of the line, which are not always the two fields the report - // sent: on Antigravity the tool's identity arrives in the detail, and drawing - // it takes the whole line (tool-label.ts). - const line = tool && toolLine(tool, session.mcpServers) // The prompt parked on this card, and how far off it is. Read at render // rather than counted down on a timer of its own: a card redraws often enough // that "in 3h" is never wrong by anything a person reads, and a clock ticking @@ -515,141 +470,12 @@ export function SessionCard({ )} - {/* One line, nine rungs: an open request, then a session blocked - on the user, then a handoff waiting for this prompt, then a - usage limit the turn stopped on, then results waiting to be collected, then how far - a quiet card got through its task list, then the - tool, then a prompt scheduled for later, then where the session - came from. A request in flight - explains the whole turn — a card working because another - session asked it to, or one stalled waiting on a card - elsewhere in the list. A block outranks the rest for the - reason it needs words at all: the amber ring differs from the - emerald one by hue alone, so nothing else on the card says the - session wants an answer. A held handoff sits under the block - and over the rest: text was handed to this session and is not - at its prompt, which without a rung is indistinguishable from a - click that did nothing. A usage limit sits there too, and for - the same reason: the card is stopped on something, and only - the rung says what and until when. The inbox sits under those and over - the tool: mid-turn the live tool is the news, and the count - takes the rung when the card goes quiet — the same rule the - relay's own nudge follows. Task-list progress answers to that - rule for a reason of its own: the tool changes at every step - and is how a card proves it is moving, while a count can sit - still for minutes, so it waits for the turn to end and then - answers the question a finished card leaves open, which is how - much the agent stopped short of. The origin is last precisely - because it is never news: it says something that has been true - since the card was created, so it surfaces only once the card - is quiet, which is when somebody scanning the sidebar is - working out where a card came from. A scheduled prompt sits - just above it for half that reason: it is not what this session - is doing, so it never takes the line from the turn — but it is - the one rung about something that has not happened yet, and a - quiet card is exactly where that is worth reading. Only one rung ever draws, - so the card grows by one row at most. */} - {relay ? ( - - {relay.direction === "out" ? ( - - ) : ( - - )} - {relay.peer ? ( - {relay.peer} - ) : ( - // Not a session, so not a label: the other end is the `lich` - // command run from a script or a shell (docs/cli.md). - command line - )} - - ) : status === "waiting" ? ( - - - {/* The question takes the whole line when there is one: the - amber glyph and the ring around the icon already say the - session is waiting, so spending the width on saying it - again would cost the card the only words on it the user - cannot already see. Not every provider has them (see - docs/hooks/session-state.md), and the generic line is what - those fall back to. */} - - {waitingReason || "Waiting on you"} - - - ) : handoffHeld ? ( - - - Something is waiting for this prompt - - ) : limit ? ( - - ) : status !== "busy" && inbox > 0 ? ( - - - - {inbox === 1 ? "1 result ready" : `${inbox} results ready`} - - - ) : status !== "busy" && todo ? ( - - - - - {`${todo.done} of ${todo.total}`} - - {" done"} - - - ) : tool ? ( - - {ToolGlyph && } - {/* The detail gives its width up first and the name only once - the detail has none left to give — which is what the lopsided - shrink factor buys. Both still shrink, so neither can push the - row past the card the way a name that refused to shrink did. - The separator travels inside the detail so it leaves with it, - instead of dangling after a truncated name. */} - - {line?.label} - - {line?.detail && ( - - · {line.detail} - - )} - - ) : scheduledAt ? ( - - - {scheduledIn ? ( - - Scheduled {scheduledIn} - - ) : ( - // Due and still here: the prompt is waiting on somewhere to - // be typed — a setup script still running, a half-written - // line at that prompt, a card whose terminal was never - // opened — and it goes in the moment there is one. - Waiting for a prompt - )} - - ) : ( - // Quieter than the rungs above it, on purpose: muted throughout - // and at normal weight, where an open request puts its peer in - // text-foreground. The word "from" earns its place — an arrow - // and a name alone read as traffic happening now, which is the - // confusion this rung exists to end. Not a link either: the - // parent may be closed, and a dead link is worse than a - // sentence. - origin && ( - - - from {origin} - - ) - )} + {/* rtl anchors the tail (project folder) to the right so overflow is clipped on the left; the leading LRM keeps "~/" in logical order instead of letting bidi push it to the end. */} diff --git a/frontend/src/components/sidebar/SessionStatusRung.tsx b/frontend/src/components/sidebar/SessionStatusRung.tsx new file mode 100644 index 000000000..86f284ab8 --- /dev/null +++ b/frontend/src/components/sidebar/SessionStatusRung.tsx @@ -0,0 +1,206 @@ +import { + ArrowLeft, + ArrowRight, + CircleQuestionMark, + Clock, + CornerDownLeft, + Hourglass, + Inbox, + ListChecks, +} from "lucide-react" +import type { Session } from "@/lib/session/sessions" +import type { SessionStatus } from "@/lib/session/session-events" +import { toolGlyph } from "@/lib/session/tool-glyph" +import { toolLine } from "@/lib/session/tool-label" +import { useSessionInbox } from "@/lib/session/use-session-inbox" +import { useSessionLimit } from "@/lib/session/use-session-limit" +import { useSessionRelay } from "@/lib/session/use-session-relay" +import { useSessionWaitingReason } from "@/lib/session/use-session-status" +import { useSessionTodo } from "@/lib/session/use-session-todo" +import { useSessionTool } from "@/lib/session/use-session-tool" +import { useHandoffHeld } from "@/lib/terminal/handoff-store" +import { SessionLimitRung } from "./SessionLimitRung" + +// The line under a card's label that says what the session is doing or waiting +// on. Its own component because each rung reads a store of its own, and the +// card only needs the line, not what decides it. +export function SessionStatusRung({ + session, + status, + origin, + scheduledIn, +}: { + session: Session + status: SessionStatus | null + origin: string + scheduledIn: string | null +}) { + // What the session is blocked on, when its provider's event had words for it: + // the line the user reads to decide whether this is the card to open. "" from + // a provider that reports the block and nothing about it. + const waitingReason = useSessionWaitingReason(session.id) + // The tool the turn is running right now, reported by the provider's pre-tool + // hook: null outside a tool call, which is what keeps the card its usual size + // whenever nothing is happening in it. + const tool = useSessionTool(session.id) + // The request this session has open with another, reported by the relay when + // a message lands in a PTY and cleared when it is answered. null the rest of + // the time, which is nearly always. + const relay = useSessionRelay(session.id) + // Text handed to this session — a pull request's conflict, the issue a + // worktree was named after — still waiting for a prompt free enough to take + // it. The wait can be minutes, and until it ends the card looks exactly like + // one nothing was handed to (write-at-prompt.ts). + const handoffHeld = useHandoffHeld(session.id) + // How many results this session has waiting in the relay's inbox: results of + // tasks it delegated, uncollected. Zero — the usual case — draws nothing. + const inbox = useSessionInbox(session.id) + const limit = useSessionLimit(session.id) + // How far the agent got through the task list it wrote for itself. null for + // most sessions: no list, a finished one, or a provider whose list lich + // cannot read (terminal.todoReaderFor). + const todo = useSessionTodo(session.id) + const ToolGlyph = tool && toolGlyph(tool.name) + // The two halves of the line, which are not always the two fields the report + // sent: on Antigravity the tool's identity arrives in the detail, and drawing + // it takes the whole line (tool-label.ts). + const line = tool && toolLine(tool, session.mcpServers) + const scheduledAt = session.scheduledAt ?? 0 + return ( + <> + {/* One line, nine rungs: an open request, then a session blocked + on the user, then a handoff waiting for this prompt, then a + usage limit the turn stopped on, then results waiting to be collected, then how far + a quiet card got through its task list, then the + tool, then a prompt scheduled for later, then where the session + came from. A request in flight + explains the whole turn — a card working because another + session asked it to, or one stalled waiting on a card + elsewhere in the list. A block outranks the rest for the + reason it needs words at all: the amber ring differs from the + emerald one by hue alone, so nothing else on the card says the + session wants an answer. A held handoff sits under the block + and over the rest: text was handed to this session and is not + at its prompt, which without a rung is indistinguishable from a + click that did nothing. A usage limit sits there too, and for + the same reason: the card is stopped on something, and only + the rung says what and until when. The inbox sits under those and over + the tool: mid-turn the live tool is the news, and the count + takes the rung when the card goes quiet — the same rule the + relay's own nudge follows. Task-list progress answers to that + rule for a reason of its own: the tool changes at every step + and is how a card proves it is moving, while a count can sit + still for minutes, so it waits for the turn to end and then + answers the question a finished card leaves open, which is how + much the agent stopped short of. The origin is last precisely + because it is never news: it says something that has been true + since the card was created, so it surfaces only once the card + is quiet, which is when somebody scanning the sidebar is + working out where a card came from. A scheduled prompt sits + just above it for half that reason: it is not what this session + is doing, so it never takes the line from the turn — but it is + the one rung about something that has not happened yet, and a + quiet card is exactly where that is worth reading. Only one rung ever draws, + so the card grows by one row at most. */} + {relay ? ( + + {relay.direction === "out" ? ( + + ) : ( + + )} + {relay.peer ? ( + {relay.peer} + ) : ( + // Not a session, so not a label: the other end is the `lich` + // command run from a script or a shell (docs/cli.md). + command line + )} + + ) : status === "waiting" ? ( + + + {/* The question takes the whole line when there is one: the + amber glyph and the ring around the icon already say the + session is waiting, so spending the width on saying it + again would cost the card the only words on it the user + cannot already see. Not every provider has them (see + docs/hooks/session-state.md), and the generic line is what + those fall back to. */} + + {waitingReason || "Waiting on you"} + + + ) : handoffHeld ? ( + + + Something is waiting for this prompt + + ) : limit ? ( + + ) : status !== "busy" && inbox > 0 ? ( + + + + {inbox === 1 ? "1 result ready" : `${inbox} results ready`} + + + ) : status !== "busy" && todo ? ( + + + + + {`${todo.done} of ${todo.total}`} + + {" done"} + + + ) : tool ? ( + + {ToolGlyph && } + {/* The detail gives its width up first and the name only once + the detail has none left to give — which is what the lopsided + shrink factor buys. Both still shrink, so neither can push the + row past the card the way a name that refused to shrink did. + The separator travels inside the detail so it leaves with it, + instead of dangling after a truncated name. */} + {line?.label} + {line?.detail && ( + + · {line.detail} + + )} + + ) : scheduledAt ? ( + + + {scheduledIn ? ( + + Scheduled {scheduledIn} + + ) : ( + // Due and still here: the prompt is waiting on somewhere to + // be typed — a setup script still running, a half-written + // line at that prompt, a card whose terminal was never + // opened — and it goes in the moment there is one. + Waiting for a prompt + )} + + ) : ( + // Quieter than the rungs above it, on purpose: muted throughout + // and at normal weight, where an open request puts its peer in + // text-foreground. The word "from" earns its place — an arrow + // and a name alone read as traffic happening now, which is the + // confusion this rung exists to end. Not a link either: the + // parent may be closed, and a dead link is worse than a + // sentence. + origin && ( + + + from {origin} + + ) + )} + + ) +}