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..e14b62ef1 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..2c205c357 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/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 40a178735..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,17 +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 { 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"
@@ -88,6 +74,7 @@ import { timeUntil } from "@/lib/session/schedule"
import { SessionTargetPicker } from "./SessionTargetPicker"
import { EntrypointDialog } from "./EntrypointDialog"
import { SchedulePromptDialog } from "./SchedulePromptDialog"
+import { SessionStatusRung } from "./SessionStatusRung"
interface SessionCardProps {
session: Session
@@ -217,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.
@@ -231,31 +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)
- // 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
@@ -512,137 +470,12 @@ 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
- 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. 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
-
- ) : 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/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/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}
+
+ )
+ )}
+ >
+ )
+}
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..87ec66195 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..872022971 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..2db8ff6f7
--- /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..4efa4b7e7
--- /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 }