Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion plugins/shelltime-statusline/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
19 changes: 18 additions & 1 deletion plugins/shelltime-statusline/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.
Expand All @@ -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 <apiEndpoint>/api/v1/cc/session-end
{"sessionId": "<session id>", "reason": "<why it ended>"}
```

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.
6 changes: 6 additions & 0 deletions plugins/shelltime-statusline/hooks/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(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 }[] }
Expand Down
25 changes: 25 additions & 0 deletions plugins/shelltime-statusline/hooks/register.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
dailyStatsRequest,
parseDailyStats,
parseUserLogin,
sessionEndRequest,
sessionProjectRequest,
userProfileRequest,
} from './api'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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($)
Expand Down
68 changes: 68 additions & 0 deletions plugins/shelltime-statusline/hooks/sessionEnd.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
Loading