Sends Claude Code usage to a SessClone deployment.

The SessClone Collector. It reports your Claude Code usage (tokens, models, cost inputs, timings) to a SessClone deployment, so you and your Org can see spend per person, Project, Device and Session on the dashboard.
/plugin marketplace add NotTahaAli/sessclone
/plugin install sessclone@sessclone
Claude Code asks for two things, both optional:
settings.json. Leave it empty in a Claude Code cloud environment whose SessClone API credential adds it.https://sessclone.com. Self-hosted deployments enter their own address.Restart Claude Code. The next session says whether it connected, and which Org it reports to. Turns from before the install are backfilled from the transcripts Claude Code still keeps.
Requires Node 22.18 or newer (or 23.6+, or 24).
/sessclone-status shows what the last session start found: the deployment, the key's first three characters and length, whether it was accepted and for which Org, plus this Device, how many of this session's Turns are not sent yet, what is waiting to send and the last push./sessclone-sync sends everything waiting now, without a Claude turn, and says what it sent.↗, which opens this session in the dashboard.These are the plugin's mod (hooks/register.tsx), which needs Claude Code 2.1.287 or newer. Where mods cannot load, /sessclone:status and /sessclone:sync do the same, except that status leaves out this session's unsent Turns, and sync is carried out by the hook that ends that turn.
Everything goes over HTTPS to the deployment URL above, with your key as a bearer token, except the opt-in transcript upload described last.
SessionStart): GET /api/ingest to ask whether the key is accepted and for which Org. If it is not, the plugin says so and sends nothing else until a session starts with a key that is. Then it re-sends what an earlier session could not (POST /api/ingest).Stop): POST /api/ingest with, per Turn, the token counts by kind, the model, timings, the Session, Project and Device it belongs to, the working directory, the git branch, and the git remote reduced to a Project key. No prompt, no reply, no file contents.StopFailure, SessionEnd): POST /api/ingest saying that a turn ended on an API error, or that a session ended.POST /api/logs/presign), uploads the compressed transcript to that URL, which is the deployment's object storage (Supabase Storage for the hosted service), and confirms it (POST /api/logs/confirm). Your Org's retention removes it.~/.claude/projects/ (or CLAUDE_CONFIG_DIR), from a cursor, so each turn costs a few hundred bytes.git in the session's working directory, to read the branch and remote.~/.local/state/sessclone, ~/Library/Application Support/sessclone, or %LOCALAPPDATA%\sessclone).HTTPS_PROXY is set, each hook restarts itself once under Node's own proxy support so its requests go through that proxy.The API key is read only from the plugin's setup prompt, never from your shell environment, and never written to a log, a file or a transcript.
| Setting | Where | Default |
|---|---|---|
| API key | setup prompt (api_key) | none, optional in cloud |
| Deployment URL | setup prompt, SESSCLONE_URL | https://sessclone.com |
| State directory | SESSCLONE_STATE_DIR | per platform, see above |
| Device name | SESSCLONE_DEVICE | derived from the machine |
| Print hook failures | SESSCLONE_DEBUG=1 | off |
To change an answer, run /plugin configure sessclone and start a new session.
Readable source, no build step: the hooks in hooks/ run as node <file>.mjs and import the modules in src/. MIT licensed, see LICENSE. Project home and issues: https://github.com/NotTahaAli/sessclone
hooks/register.tsx 183 lines1// The plugin's mod (Claude Code 2.1.287 and later; older versions ignore
2// `modules` in hooks.json and keep collecting through the settings hooks).
3//
4// Two commands, `/sessclone-status` and `/sessclone-sync`, and a one-line bar
5// above the prompt: whether the key is connected, how many of this session's
6// Turns are not sent yet, what is queued, and a link to this session in the
7// dashboard.
8//
9// A mod has no Node, so the facts come from the scripts the skills
10// run, started with `$.process.run`. The bar is refreshed when a session
11// starts and after each turn's `Stop` hook has flushed, never on a timer:
12// nothing the bar shows changes in between.
13
14import { atom, read, update } from 'claude-code'
15import type { EngineInterface, Register } from 'claude-code'
16
17import type { Standing } from '../types'
18
19import { barParts, dotColor, optionEnvironment, sessionLink } from './bar.ts'
20
21const standing = atom(
22 { plugin: 'sessclone', key: 'standing' } as const,
23 null as Standing | null,
24)
25
26/** The setup prompt's answers, as `register` received them. */
27let optionEnv: Record<string, string> = {}
28
29/**
30 * Runs one of the plugin's scripts. Only sync is handed the setup prompt's
31 * answers, the key among them: status reads what the hooks recorded, and an
32 * env passed here is visible to any mod hooked on `process.run`.
33 */
34const script = (
35 $: EngineInterface,
36 name: 'status.mjs' | 'sync.mjs',
37 args: string[] = [],
38) =>
39 $.process.run(['node', `${$.plugin.root}/scripts/${name}`, ...args], {
40 ...(name === 'sync.mjs' ? { env: optionEnv } : {}),
41 // Sync's own budget is 20 seconds, behind a 3-second key check.
42 timeoutMs: name === 'sync.mjs' ? 60_000 : 30_000,
43 })
44
45/** What a command answers when its script could not run at all. */
46const failed = (what: string) =>
47 `sessclone ${what} could not run: is \`node\` on Claude Code's PATH? Node 22.18 or newer.`
48
49/** Which refresh is newest, so a slow one never overwrites a later one. */
50let generation = 0
51
52const refresh = async ($: EngineInterface) => {
53 // Only the terminal and the Desktop app draw the band; `claude -p`, the SDK,
54 // cloud sessions and the VS Code panel have no bar to feed.
55 const surfaces = await $.session.surfaces()
56 if (
57 !surfaces.some((surface) => surface === 'terminal' || surface === 'desktop')
58 )
59 return
60 const mine = ++generation
61 const sessionId = await $.session.id()
62 const { stdout } = await script($, 'status.mjs', [
63 '--json',
64 '--session',
65 sessionId,
66 ])
67 const parsed = JSON.parse(stdout)
68 if (parsed.error || mine !== generation) return
69 await update($, standing, () => ({ ...parsed, sessionId }))
70}
71
72/** After the dispatch that asked, so a turn never waits on the bar. */
73const refreshSoon = ($: EngineInterface) => {
74 $.clock.after(0, () => refresh($).catch(() => undefined))
75}
76
77export const register: Register = (on, options) => {
78 optionEnv = optionEnvironment(options)
79
80 on('session.start', async ($, e, next) => {
81 await $.command.register({
82 name: 'sessclone-status',
83 description:
84 'Shows whether the sessclone Collector is connected, which Org it reports to, and what is waiting to send.',
85 immediate: true,
86 })
87 await $.command.register({
88 name: 'sessclone-sync',
89 description:
90 'Sends everything the sessclone Collector has waiting now, instead of at the next session start.',
91 })
92 // No refresh here: `classic.SessionStart` below runs once the key check
93 // has been recorded, which is what the bar shows first.
94 return next(e)
95 })
96
97 // A surface that joins later (a Desktop or remote client) gets the bar.
98 on('session.attach', async ($, e, next) => {
99 const result = await next(e)
100 refreshSoon($)
101 return result
102 })
103
104 // Both after the settings hooks beneath have run: the session-start check
105 // has recorded the connection, and `Stop` has flushed the turn.
106 on('classic.SessionStart', async ($, e, next) => {
107 const result = await next(e)
108 refreshSoon($)
109 return result
110 })
111 on('classic.Stop', async ($, e, next) => {
112 const result = await next(e)
113 refreshSoon($)
114 return result
115 })
116
117 // The skills `/sessclone:status` and `/sessclone:sync` stay for a Claude
118 // Code that cannot load this mod (older than 2.1.287, or mods turned off).
119 // Where it does load, the commands above replace them in the menu.
120 for (const command of ['sessclone:status', 'sessclone:sync']) {
121 on('command.describe', { command }, async ($, e, next) => ({
122 ...(await next(e)),
123 isHidden: true,
124 }))
125 }
126
127 on('command.run', { command: 'sessclone-status' }, async ($) => {
128 const sessionId = await $.session.id()
129 const { stdout } = await script($, 'status.mjs', [
130 '--session',
131 sessionId,
132 ]).catch(() => ({ stdout: '' }))
133 refreshSoon($)
134 return { text: stdout.trim() || failed('status') }
135 })
136
137 on('command.run', { command: 'sessclone-sync' }, async ($) => {
138 const { stdout } = await script($, 'sync.mjs').catch(() => ({
139 stdout: '',
140 }))
141 refreshSoon($)
142 return { text: stdout.trim() || failed('sync') }
143 })
144
145 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
146 const s = await read($, standing)
147 if (e.props.hasSurvey || !s) return next(e)
148
149 const { Box, Text, Link } = $.ui.resolve(e)
150 const href = sessionLink(s.url, s.sessionId)
151 // The band is shared: a tree replaces what the mods after this one draw,
152 // unless it carries theirs.
153 const others = await next(e)
154
155 return (
156 <Box flexDirection="column">
157 {/* Short of the band's own [-] mark, so the words are cut, not the link. */}
158 <Box
159 flexDirection="row"
160 gap={1}
161 width={Math.max(10, e.props.bodyColumns - 4)}
162 >
163 <Text color={dotColor(s)}>●</Text>
164 <Box flexShrink={1}>
165 <Text dimColor wrap="truncate-end">
166 sessclone · {barParts(s).join(' · ')}
167 </Text>
168 </Box>
169 {/* Kept whole when the line is cut: the words give way first. */}
170 {href ? (
171 <Box flexShrink={0}>
172 <Text>
173 <Link href={href} label="↗" />
174 </Text>
175 </Box>
176 ) : null}
177 </Box>
178 {others}
179 </Box>
180 )
181 })
182}
183hooks/bar.ts 74 lines1// The status bar's words, colour and link, apart from the mod so they are
2// tested without a session (`bar.test.mjs`).
3
4import type { Standing } from '../types'
5
6/**
7 * Claude Code gives a hook these; the mod hands them on to sync. Every string
8 * answer is set, an empty one too, so a variable of the same name already in
9 * Claude Code's environment never stands in for the setup prompt.
10 */
11export const optionEnvironment = (
12 options: Readonly<Record<string, unknown>>,
13) => {
14 const env: Record<string, string> = {
15 CLAUDE_PLUGIN_OPTION_URL: '',
16 CLAUDE_PLUGIN_OPTION_API_KEY: '',
17 }
18 for (const [name, value] of Object.entries(options)) {
19 if (typeof value === 'string') {
20 env[`CLAUDE_PLUGIN_OPTION_${name.toUpperCase()}`] = value
21 }
22 }
23 return env
24}
25
26/**
27 * The dashboard page for a session, under the deployment's own path, or null
28 * where a surface would refuse the link (and with it the whole bar): only
29 * `https:`, or `http:` on localhost, with no credentials in it.
30 */
31export const sessionLink = (url: string | null, sessionId: string) => {
32 if (!url) return null
33 try {
34 const base = new URL(url)
35 const allowed =
36 base.protocol === 'https:' ||
37 (base.protocol === 'http:' && base.hostname === 'localhost')
38 if (!allowed || base.username || base.password) return null
39 base.pathname = `${base.pathname.replace(/\/$/, '')}/sessions/${encodeURIComponent(sessionId)}`
40 base.search = ''
41 base.hash = ''
42 return base.href
43 } catch {
44 return null
45 }
46}
47
48/** The bar's words, left to right. */
49export const barParts = (s: Standing) => {
50 const parts: string[] = []
51 if (s.oldNode) parts.push('needs Node 22.18+')
52 else if (s.connection === 'connected')
53 parts.push(s.org ? `connected · ${s.org}` : 'connected')
54 else if (s.connection === 'refused') parts.push('key refused')
55 else parts.push('not checked yet')
56
57 if (s.unsent !== null) {
58 parts.push(
59 s.unsent === 0
60 ? 'session synced'
61 : `${s.unsent} Turn${s.unsent === 1 ? '' : 's'} behind`,
62 )
63 }
64 if (s.queued > 0) parts.push(`${s.queued} queued`)
65 return parts
66}
67
68export const dotColor = (s: Standing) =>
69 s.oldNode || s.connection === 'refused'
70 ? 'error'
71 : s.connection === 'connected' && (s.unsent ?? 0) === 0 && s.queued === 0
72 ? 'success'
73 : 'warning'
74types/index.d.ts 19 lines1/** What `scripts/status.mjs --json` prints, and the session it was asked about. */
2export type Standing = {
3 url: string | null
4 connection: 'connected' | 'refused' | 'unchecked'
5 org: string | null
6 checkedAt: string | null
7 oldNode: boolean
8 queued: number
9 unsent: number | null
10 lastPush: { status: number | null; at: string } | null
11 sessionId: string
12}
13
14declare module 'claude-code' {
15 interface PluginState {
16 sessclone: { standing: Standing | null }
17 }
18}
19