SLOPSHOPPER

sessclone

Sends Claude Code usage to a SessClone deployment.

newbandcommandprocesstimer
★ 3v0.5.0MITupdated 2026-10-07NotTahaAli/sessclone/packages/plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · sessclone
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /sessclone-status ⎿ sessclone: dev ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

sessclone

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.

Install

/plugin marketplace add NotTahaAli/sessclone
/plugin install sessclone@sessclone

Claude Code asks for two things, both optional:

  • API key: create one in the dashboard under Keys. It is shown once and kept in Claude Code's secure credential store, not in settings.json. Leave it empty in a Claude Code cloud environment whose SessClone API credential adds it.
  • Deployment URL: leave it empty for the hosted service at 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).

Commands and the status bar

  • /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.
  • A line above the prompt shows whether the key is connected, whether this session is synced or how many Turns it is behind, what is queued, and ↗, 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.

What it sends, and where

Everything goes over HTTPS to the deployment URL above, with your key as a bearer token, except the opt-in transcript upload described last.

  • Session start (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).
  • Every turn (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.
  • Session events (StopFailure, SessionEnd): POST /api/ingest saying that a turn ended on an API error, or that a session ended.
  • Transcripts, only if you opt in: the raw session transcript, which does contain prompts and code, is uploaded only when your Member settings on the deployment turn transcript upload on. It is off by default. The plugin asks the deployment for an upload URL (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.

What it reads and runs locally

  • Claude Code's own transcripts under ~/.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.
  • A state directory for cursors, the retry queue, and the last key check (~/.local/state/sessclone, ~/Library/Application Support/sessclone, or %LOCALAPPDATA%\sessclone).
  • When 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.

Settings

SettingWhereDefault
API keysetup prompt (api_key)none, optional in cloud
Deployment URLsetup prompt, SESSCLONE_URLhttps://sessclone.com
State directorySESSCLONE_STATE_DIRper platform, see above
Device nameSESSCLONE_DEVICEderived from the machine
Print hook failuresSESSCLONE_DEBUG=1off

To change an answer, run /plugin configure sessclone and start a new session.

Source and license

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

Source 3 files
hooks/register.tsx 183 lines
1// 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}
183
hooks/bar.ts 74 lines
1// 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'
74
types/index.d.ts 19 lines
1/** 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