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 }