From efdff72304c224e1789b4f30869f977ca79ae98a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 02:33:14 +0000 Subject: [PATCH] feat(statusline): report session end so ShellTime summarizes it right away Register a session.end hook that POSTs {sessionId, reason} to /api/v1/cc/session-end. ShellTime then writes the session's AI summary about 30 seconds later and updates the PR cost comment right after, instead of waiting for its 20-minute or 24-hour runs. The request runs alongside the engine's own end step, inside the exit's shared 1.5 s bound, and never throws, so an exit is never held up or failed. Without a token nothing is sent. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016C1wVAG9ZuUfqMapByqem9 --- .claude-plugin/marketplace.json | 2 +- README.md | 4 +- .../.claude-plugin/plugin.json | 2 +- plugins/shelltime-statusline/README.md | 19 +++++- plugins/shelltime-statusline/hooks/api.ts | 6 ++ .../shelltime-statusline/hooks/register.tsx | 25 +++++++ .../hooks/sessionEnd.test.ts | 68 +++++++++++++++++++ 7 files changed, 122 insertions(+), 4 deletions(-) create mode 100644 plugins/shelltime-statusline/hooks/sessionEnd.test.ts diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index d147f91..5511937 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -11,7 +11,7 @@ { "name": "shelltime-statusline", "source": "./plugins/shelltime-statusline", - "description": "ShellTime statusline for Claude Code: git, model, session and daily cost, quota, agent time and context, in the terminal and the desktop app. Also links PRs opened with gh pr create to the session.", + "description": "ShellTime statusline for Claude Code: git, model, session and daily cost, quota, agent time and context, in the terminal and the desktop app. Also links PRs opened with gh pr create to the session, and reports when the session ends so ShellTime summarizes it right away.", "category": "productivity" } ] diff --git a/README.md b/README.md index 9dd32bc..1541d17 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ A mod is a Claude Code plugin made of function hooks: a small TypeScript module | Mod | What it does | | --- | --- | -| [`shelltime-statusline`](plugins/shelltime-statusline) | The ShellTime statusline (git, model, session and daily cost, quota, agent time, context) above the prompt, in the terminal and the desktop app. Also links PRs opened with `gh pr create` to the session on shelltime.xyz | +| [`shelltime-statusline`](plugins/shelltime-statusline) | The ShellTime statusline (git, model, session and daily cost, quota, agent time, context) above the prompt, in the terminal and the desktop app. Also links PRs opened with `gh pr create` to the session on shelltime.xyz, and reports when the session ends so ShellTime summarizes it right away | ## What you get @@ -30,6 +30,8 @@ Before you run `shelltime init`, it still shows git, model, session cost, quota When Claude opens a pull request with `gh pr create`, the mod links that PR to the session on shelltime.xyz. This goes through the `shelltime` CLI and its daemon. If the repository has the ShellTime GitHub App installed, ShellTime also comments on the PR with the session's tokens, cost and time, and a link to the session. +When the session ends, the mod tells ShellTime, which writes the session's AI summary about 30 seconds later and then updates the PR comment with it, instead of waiting for its timed runs. + The [mod's README](plugins/shelltime-statusline/README.md) has every segment's colors and where each number comes from. ## Install diff --git a/plugins/shelltime-statusline/.claude-plugin/plugin.json b/plugins/shelltime-statusline/.claude-plugin/plugin.json index 2a0cdf4..8546ac6 100644 --- a/plugins/shelltime-statusline/.claude-plugin/plugin.json +++ b/plugins/shelltime-statusline/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "shelltime-statusline", "version": "0.1.3", - "description": "ShellTime statusline for Claude Code: git, model, session and daily cost, quota, agent time and context, in the terminal and the desktop app. Also links PRs opened with gh pr create to the session.", + "description": "ShellTime statusline for Claude Code: git, model, session and daily cost, quota, agent time and context, in the terminal and the desktop app. Also links PRs opened with gh pr create to the session, and reports when the session ends so ShellTime summarizes it right away.", "author": { "name": "ShellTime", "url": "https://shelltime.xyz" diff --git a/plugins/shelltime-statusline/README.md b/plugins/shelltime-statusline/README.md index bdea0db..863e3c6 100644 --- a/plugins/shelltime-statusline/README.md +++ b/plugins/shelltime-statusline/README.md @@ -49,6 +49,7 @@ The statusline doesn't need the `shelltime` binary or its daemon. Without a toke | daily cost, agent time | the daemon queries ShellTime's API | the same GraphQL query, at most once every 15 s | | session → project mapping | sent to ShellTime's API | the same request, once per session and directory | | session → pull requests | | `shelltime cc pr`, after `gh pr create` prints a PR URL | +| session end | | one request to ShellTime's API when the session ends | Like the native statusline, it refreshes as the conversation changes: when you send a prompt, after each tool call, and when a turn ends. It doesn't poll while the session is idle. @@ -77,8 +78,9 @@ If the ShellTime GitHub App is installed on the repository, ShellTime comments o - the model - prompts and lines changed - a link to the session on shelltime.xyz, which only you can open +- once ShellTime has summarized the session: its AI title, a one-line description and the summary -The comment is posted a couple of minutes after the PR is linked. ShellTime edits the same comment 30 minutes and 24 hours later, so it ends with the whole session's numbers. +The comment is posted a couple of minutes after the PR is linked. When the session ends, ShellTime edits it again right after summarizing the session (see below), then 30 minutes and 24 hours after the link, so it ends with the whole session's numbers. - If you turned off showing your AI cost publicly on shelltime.xyz, the comment leaves out the USD amounts. - Delete the comment and it is not posted again. @@ -88,6 +90,21 @@ The comment is posted a couple of minutes after the PR is linked. ShellTime edit The mod and the CLI do nothing extra for this: ShellTime's server posts the comment once the PR is linked. +## Session end + +When the session ends (you exit, `/clear`, `/resume` another session, log out, or a `-p` run finishes), the mod tells ShellTime: + +``` +POST /api/v1/cc/session-end +{"sessionId": "", "reason": ""} +``` + +About 30 seconds later, once the telemetry Claude Code sends on exit has arrived, ShellTime writes the session's AI title, description and summary. Right after that it updates the cost comment on the session's PRs. Without this, the summary waits for ShellTime's timed runs, 20 minutes or 24 hours after the session's activity. + +- It's one request, with no retry. Claude Code gives everything that runs at the end of a session 1.5 seconds, and this runs alongside its own end step. If the request doesn't make it, the timed runs still summarize the session. +- ShellTime's 24-hour run stays as a last check. It only summarizes the session again if it changed, for example after a `claude --resume`. +- Without a token, nothing is sent. Failures go to Claude Code's debug log. The exit is never held up or fails because of it. + ## Terminal If `~/.claude/settings.json` also has a `statusLine` running `shelltime cc statusline`, the terminal shows both lines. Keep both, or remove one. diff --git a/plugins/shelltime-statusline/hooks/api.ts b/plugins/shelltime-statusline/hooks/api.ts index 628cb0c..c5a986f 100644 --- a/plugins/shelltime-statusline/hooks/api.ts +++ b/plugins/shelltime-statusline/hooks/api.ts @@ -80,6 +80,12 @@ export function sessionProjectRequest( return post(config, '/api/v1/cc/session-project', { sessionId, projectPath }) } +// shelltime/server handler/cc_session_end.go: the session is over, so ShellTime +// summarizes it and comments on its pull requests now. +export function sessionEndRequest(config: ShellTimeConfig, sessionId: string, reason: string): ApiRequest { + return post(config, '/api/v1/cc/session-end', { sessionId, reason }) +} + function graphqlData(res: ApiResponse): T { if (!res.ok) throw new Error(`HTTP error: ${res.status}`) const body = JSON.parse(res.text) as { data?: T; errors?: { message: string }[] } diff --git a/plugins/shelltime-statusline/hooks/register.tsx b/plugins/shelltime-statusline/hooks/register.tsx index 19e4025..778c6c1 100644 --- a/plugins/shelltime-statusline/hooks/register.tsx +++ b/plugins/shelltime-statusline/hooks/register.tsx @@ -7,6 +7,7 @@ import { dailyStatsRequest, parseDailyStats, parseUserLogin, + sessionEndRequest, sessionProjectRequest, userProfileRequest, } from './api' @@ -156,6 +157,21 @@ async function linkPullRequests($: EngineInterface, sessionId: string, urls: rea } } +// Tells ShellTime the session is over, so it summarizes the session and +// comments on its PRs now instead of on a timed run. One POST and no retry: +// every session.end hook shares the exit's 1.5 s bound, and the server's timed +// runs cover a report that doesn't make it. +async function sendSessionEnd($: EngineInterface, sessionId: string, reason: string) { + if (sessionId === '') return + try { + const config = await loadConfig($) + if (config.token === '') return + checkOk(await send($, sessionEndRequest(config, sessionId, reason))) + } catch (err) { + logOnce($, 'session-end', err) + } +} + // Daily cost, agent time and login from ShellTime's API. async function refreshRemote($: EngineInterface, sessionId: string, cwd: string) { if (isRemoteRunning) return @@ -276,6 +292,15 @@ export const register: Register = on => { return result }) + on('session.end', async ($, e, next) => { + // Alongside the engine's own end step, not ahead of it, so the report + // never pushes that step past the exit's bound. + const sent = sendSessionEnd($, e.sessionId, e.reason) + const ended = await next(e) + await sent + return ended + }) + on('turn.complete', async ($, e, next) => { const result = await next(e) schedule($) diff --git a/plugins/shelltime-statusline/hooks/sessionEnd.test.ts b/plugins/shelltime-statusline/hooks/sessionEnd.test.ts new file mode 100644 index 0000000..b6ac835 --- /dev/null +++ b/plugins/shelltime-statusline/hooks/sessionEnd.test.ts @@ -0,0 +1,68 @@ +import { expect, mock, test } from 'claude-code/testing' +import type { On } from 'claude-code' + +const SESSION_END_URL = 'https://api.shelltime.xyz/api/v1/cc/session-end' + +type Sent = { url: string; body: string; auth: string | undefined } + +// The world beneath the plugin: the engine's end step, the CLI's config holding +// `token` when one is given, and ShellTime's API answering `answer`. +function world(on: On, token?: string, answer: () => unknown = () => ({ status: 204, ok: true, headers: {}, text: '' })) { + mock.env(on, { HOME: '/home/me' }) + on('session.end', ($, e) => ({ sessionId: e.sessionId })) + on('fs.read', ($, e) => + token !== undefined && e.path === '/home/me/.shelltime/config.yaml' + ? { value: `token: ${token}\n` } + : { deny: 'ENOENT' }, + ) + const requests: Sent[] = [] + on('http.fetch', ($, e) => { + requests.push({ url: e.url, body: e.init?.body ?? '', auth: e.init?.headers?.Authorization }) + return { value: answer() } + }) + return { requests } +} + +test('tells ShellTime the session ended, for every reason', async ($, on) => { + const { requests } = world(on, 'tok-123') + + for (const reason of ['prompt_input_exit', 'clear', 'resume', 'logout', 'other'] as const) { + const ended = await $.session.end({ reason, sessionId: `sess-${reason}`, resume: { id: `sess-${reason}` } }) + expect(ended).toEqual({ sessionId: `sess-${reason}` }) + } + + expect(requests).toEqual( + ['prompt_input_exit', 'clear', 'resume', 'logout', 'other'].map(reason => ({ + url: SESSION_END_URL, + body: JSON.stringify({ sessionId: `sess-${reason}`, reason }), + auth: 'CLI tok-123', + })), + ) +}) + +test('without a ShellTime token: nothing is sent', async ($, on) => { + const { requests } = world(on) + + const ended = await $.session.end({ reason: 'other', sessionId: 'sess-1', resume: { id: 'sess-1' } }) + + expect(ended).toEqual({ sessionId: 'sess-1' }) + expect(requests).toEqual([]) +}) + +test('a failed report never holds up or fails the exit', async ($, on) => { + const { requests } = world(on, 'tok-123', () => { + throw new Error('connect ECONNREFUSED') + }) + expect(await $.session.end({ reason: 'other', sessionId: 'sess-1', resume: { id: 'sess-1' } })).toEqual({ + sessionId: 'sess-1', + }) + expect(requests).toHaveLength(1) +}) + +test('a server error never fails the exit', async ($, on) => { + const { requests } = world(on, 'tok-123', () => ({ status: 500, ok: false, headers: {}, text: '' })) + expect(await $.session.end({ reason: 'other', sessionId: 'sess-1', resume: { id: 'sess-1' } })).toEqual({ + sessionId: 'sess-1', + }) + expect(requests).toHaveLength(1) +})