SLOPSHOPPER

consolidated-inbox

One panel with the open questions of all your Claude sessions. Answer from there and the answer goes to the session. Needs the inbox mod in each session…

newpanecommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · consolidated-inbox
│ ┃ Consolidated inbox ✕ › fix the failing auth test and add an audit log call │ ┃ No open questions. Sessions with the inbox │ ┃ mod installed show up here when they ask ⏺ Read(src/auth.ts) │ ┃ one. ⎿ 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 │ │ › /consolidated-inbox │ ⎿ consolidated-inbox: Opened the consolidated inbox. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Consolidated inbox
No open questions. Sessions with the inbox mod installed show up here when they ask one.
README

hardkoded-skills

A collection of skills by hardkoded packaged as plugins for Claude, Cursor, GitHub Copilot, and Codex.

Included skills

This is a growing collection — more skills will land here over time. Currently:

  • cronito — an interactive CLI for managing scheduled tasks on your machine, backed by a single system cron entry. This skill vendors a prebuilt, dependency-free bundle, so there's nothing to install to use it. Source lives at hardkoded/cronito. Run /cronito init once to set it up.
  • pr-memory — PR-keyed, on-disk session memory. save snapshots the current session as a handoff-style document (worktree, per-repo branches, every related PR), and load <pr> re-hydrates it later by any of those PR numbers. Works standalone in a single repo, or across a multi-repo workspace if you use one. Memories are pruned automatically once every PR they reference has merged.
  • open-issue-orchestrator — an orchestrator that pulls open GitHub issues, launches one subagent per issue in its own worktree, then watches the pull requests for build errors, merge conflicts, and review comments and sends them back to the right subagent. It decides minor things itself and only asks you critical questions. It never merges.
  • user-email — an optional Claude Code mod that shows the signed-in account email above the prompt. Not installed with hardkoded; install it separately with /plugin install user-email@hardkoded-skills if you want it.
  • usage-forecast — an optional Claude Code mod that projects where the 5-hour usage window will land at reset, shown above the prompt. Not installed with hardkoded; install it separately with /plugin install usage-forecast@hardkoded-skills if you want it.
  • inbox — an optional Claude Code mod, from petekp/inbox (MIT), that gathers the questions, tasks, findings and PRs waiting on you in a session. It always writes its open rows to inbox/status in your Claude config folder. Install it separately with /plugin install inbox@hardkoded-skills if you want it.
  • consolidated-inbox — an optional Claude Code mod that adds /consolidated-inbox: one panel with the open questions of all your sessions. Answer there and the answer goes to the session that asked. It reads the files the inbox mod writes, so install inbox in each session you want to see. Install it with /plugin install consolidated-inbox@hardkoded-skills.

Installation

Claude Code

/plugin marketplace add hardkoded/hardkoded-skills
/plugin install hardkoded@hardkoded

Cursor

Import this repository as a team marketplace, then install hardkoded from the Cursor plugin UI.

GitHub Copilot (VS Code)

Use Chat: Install Plugin From Source and provide:

https://github.com/hardkoded/hardkoded-skills

Codex

codex plugin marketplace add hardkoded/hardkoded-skills
codex plugin add hardkoded@hardkoded

Repository layout

hardkoded-skills/
├── .claude-plugin/marketplace.json
├── .cursor-plugin/marketplace.json
├── plugin.json
└── plugins/
    └── hardkoded/
        ├── .claude-plugin/plugin.json
        ├── .cursor-plugin/plugin.json
        ├── .codex-plugin/plugin.json
        └── skills/
            ├── cronito/
            │   ├── SKILL.md
            │   ├── cronito.sh
            │   └── dist/cronito.cjs
            ├── open-issue-orchestrator/
            │   └── SKILL.md
            └── pr-memory/
                ├── SKILL.md
                └── scripts/

Every install path above (Claude, Cursor, Copilot, Codex) resolves skills through a plugin manifest's "skills" field, which points at plugins/hardkoded/skills/ — that's the single copy of each skill in this repo. Each skill only ships its built artifact here — source lives in its own repo (e.g. hardkoded/cronito).

License

MIT

Source 3 files
hooks/register.tsx 216 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { answerText, openSessions, parseStatus, relayText, sessionLabel } from './status'
5import type { Sent, SessionStatus } from '../types'
6
7const PANE = 'consolidated-inbox'
8const POLL_MS = 5000
9
10const SESSIONS = atom({ plugin: 'consolidated-inbox', key: 'sessions' } as const, [] as SessionStatus[])
11const SENT = atom({ plugin: 'consolidated-inbox', key: 'sent' } as const, {} as Record<string, Sent>)
12const TYPING = atom({ plugin: 'consolidated-inbox', key: 'typing' } as const, null as string | null)
13
14let isOpen = false
15let isPolling = false
16
17/** The folder the inbox mod writes to. It must resolve the way the inbox's `exportStatus` does. */
18async function statusDir($: EngineInterface): Promise<string> {
19  const set = await $.env.get('INBOX_STATUS_DIR')
20  if (set) return set
21  const config = (await $.env.get('CLAUDE_CONFIG_DIR')) || `${(await $.env.get('HOME')) ?? ''}/.claude`
22
23  return `${config}/inbox/status`
24}
25
26async function refresh($: EngineInterface) {
27  const dir = await statusDir($)
28  // A folder that cannot be listed keeps the last list and the sent marks. One that is not there yet is empty.
29  const names = await $.fs.list(dir).then(
30    entries => entries.filter(e => e.kind === 'file' && e.name.endsWith('.json')).map(e => e.name),
31    (err: unknown) => {
32      if ((err as { code?: unknown }).code === 'ENOENT') return [] as string[]
33      throw err
34    },
35  )
36  const files = await Promise.all(names.map(name => $.fs.read(`${dir}/${name}`).then(parseStatus, () => null)))
37  const found = files.filter((s): s is SessionStatus => s !== null)
38  const [now, ownId] = await Promise.all([$.clock.now(), $.session.id()])
39
40  const open = openSessions(found, now, ownId)
41  const ids = new Set(open.flatMap(s => s.questions.map(q => `${s.sessionId}:${q.id}`)))
42
43  await update($, SESSIONS, () => open)
44  // A sent answer is forgotten once its question leaves the list, so a reused id starts clean.
45  await update($, SENT, sent => Object.fromEntries(Object.entries(sent).filter(([key]) => ids.has(key))))
46}
47
48/** Reads the status folder every few seconds while the pane is open. */
49function poll($: EngineInterface) {
50  if (isPolling) return
51  isPolling = true
52  void (async () => {
53    try {
54      while (isOpen) {
55        await refresh($).catch(() => undefined)
56        await $.clock.sleep(POLL_MS)
57      }
58    } finally {
59      isPolling = false
60    }
61  })().catch(() => undefined)
62}
63
64const sending = new Set<string>()
65
66const AUTO_MODE = /auto mode|classifier/i
67
68/** A refused send is relayed only when auto mode refused it. A session that is gone cannot be reached either way. */
69const refused = ($: EngineInterface, session: SessionStatus, id: string, text: string, why: string): Promise<Sent> =>
70  AUTO_MODE.test(why) ? relay($, session, id, text, why) : Promise.resolve({ is: 'failed', reason: why })
71
72/** Asks this session's Claude to send the answer, since it is the model's own SendMessage call that auto mode can judge. */
73function relay($: EngineInterface, session: SessionStatus, id: string, text: string, why: string): Promise<Sent> {
74  return $.prompt.submit({ text: relayText(session.sessionId, id, text), asUser: true }).then(
75    (r): Sent => (r.drop === undefined ? { is: 'relayed' } : { is: 'failed', reason: `${why}; ${r.drop}` }),
76    (): Sent => ({ is: 'failed', reason: why }),
77  )
78}
79
80async function answer($: EngineInterface, session: SessionStatus, id: string, ask: string, text: string) {
81  const key = `${session.sessionId}:${id}`
82  if (sending.has(key)) return
83  sending.add(key)
84  try {
85    const outcome = await $.session.send({ to: { sessionId: session.sessionId }, text: answerText(ask, text) }).then(
86      (r): Promise<Sent> => (r.isDelivered ? Promise.resolve({ is: 'sent' }) : refused($, session, id, text, r.reason)),
87      (err: unknown) => refused($, session, id, text, err instanceof Error ? err.message : String(err)),
88    )
89
90    await Promise.all([
91      update($, SENT, s => ({ ...s, [key]: outcome })),
92      update($, TYPING, t => (t === key && outcome.is !== 'failed' ? null : t)),
93    ])
94  } finally {
95    sending.delete(key)
96  }
97}
98
99export const register: Register = on => {
100  on('session.start', async ($, e, next) => {
101    await $.command
102      .register({
103        name: 'consolidated-inbox',
104        description: 'Answer the open questions of all your sessions',
105        immediate: true,
106      })
107      .catch(() => undefined)
108
109    return next(e)
110  })
111
112  on('command.run', { command: 'consolidated-inbox' }, async $ => {
113    isOpen = true
114    await $.ui.open({ id: PANE, title: 'Consolidated inbox', focus: true, closeOnEscape: true })
115    poll($)
116
117    return { text: 'Opened the consolidated inbox.' }
118  })
119
120  on('ui.close', async ($, e, next) => {
121    if (e.id === PANE) isOpen = false
122
123    return next(e)
124  })
125
126  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
127    const elements = $.ui.resolve(e)
128    const { Box, Button, Text } = elements
129    const Input = 'Input' in elements ? elements.Input : null
130    const [sessions, sent, typing] = await Promise.all([read($, SESSIONS), read($, SENT), read($, TYPING)])
131
132    if (sessions.length === 0)
133      return (
134        <Box flexDirection="column" paddingLeft={1}>
135          <Text dimColor wrap="wrap">
136            No open questions. Sessions with the inbox mod installed show up here when they ask one.
137          </Text>
138        </Box>
139      )
140
141    return (
142      <Box flexDirection="column" paddingLeft={1} gap={1}>
143        {sessions.map(session => (
144          <Box key={session.sessionId} flexDirection="column">
145            <Text bold>
146              {sessionLabel(session)}
147              {session.project ? <Text dimColor> {session.project}</Text> : null}
148            </Text>
149            {session.questions.map(q => {
150              const key = `${session.sessionId}:${q.id}`
151              const outcome = sent[key]
152              const reply = (text: string) => void answer($, session, q.id, q.ask, text)
153
154              return (
155                <Box key={key} flexDirection="column" marginLeft={2} marginTop={1}>
156                  <Text wrap="wrap">{q.ask}</Text>
157                  {outcome?.is === 'sent' || outcome?.is === 'relayed' ? (
158                    <Box flexDirection="row" columnGap={1}>
159                      <Text color="success">
160                        {outcome.is === 'relayed' ? 'Handed to Claude to send' : 'Sent'}. It leaves this list when the
161                        session updates.
162                      </Text>
163                      <Button
164                        key={`${key}:again`}
165                        plain
166                        label="Answer again"
167                        onPress={() =>
168                          void update($, SENT, s => Object.fromEntries(Object.entries(s).filter(([k]) => k !== key)))
169                        }
170                      />
171                    </Box>
172                  ) : (
173                    <Box flexDirection="column">
174                      {outcome?.is === 'failed' ? (
175                        <Text color="error" wrap="wrap">
176                          Not sent: {outcome.reason ?? 'the session did not take it'}
177                        </Text>
178                      ) : null}
179                      <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
180                        {q.options.map(option => (
181                          <Button
182                            key={`${key}:${option}`}
183                            label={q.rec === option ? `${option} (recommended)` : option}
184                            onPress={() => reply(option)}
185                          />
186                        ))}
187                        <Button
188                          key={`${key}:type`}
189                          plain
190                          label="Reply…"
191                          onPress={() => void update($, TYPING, t => (t === key ? null : key))}
192                        />
193                      </Box>
194                      {Input && typing === key ? (
195                        <Input
196                          key={`${key}:input`}
197                          placeholder="Your answer"
198                          submitLabel="send"
199                          autoFocus
200                          onSubmit={(value: string) => {
201                            if (value.trim()) reply(value.trim())
202                          }}
203                        />
204                      ) : null}
205                    </Box>
206                  )}
207                </Box>
208              )
209            })}
210          </Box>
211        ))}
212      </Box>
213    )
214  })
215}
216
hooks/status.ts 82 lines
1import type { Question, SessionStatus } from '../types'
2
3/** A status file is stale after this long without a write. The inbox removes a file when its session ends, so this only catches a session that crashed. */
4export const STALE_MS = 24 * 60 * 60 * 1000
5
6const SESSION_ID = /^[\w-]{1,100}$/
7
8const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null
9
10const text = (v: unknown): string | null => (typeof v === 'string' ? v : null)
11
12/**
13 * A session's open questions, from the file the inbox mod writes (version 1). Null for text that is not such a file.
14 * Tasks and findings in the file are left out.
15 */
16export function parseStatus(raw: string): SessionStatus | null {
17  let file: unknown
18  try {
19    file = JSON.parse(raw)
20  } catch {
21    return null
22  }
23  if (
24    !isRecord(file) ||
25    file.version !== 1 ||
26    typeof file.sessionId !== 'string' ||
27    !SESSION_ID.test(file.sessionId) ||
28    !Array.isArray(file.items)
29  )
30    return null
31
32  const questions = file.items.flatMap((item: unknown): Question[] => {
33    if (!isRecord(item) || item.kind !== 'question' || typeof item.id !== 'string' || typeof item.ask !== 'string')
34      return []
35
36    return [
37      {
38        id: item.id,
39        ask: item.ask,
40        options: Array.isArray(item.options) ? item.options.filter((o): o is string => typeof o === 'string') : [],
41        rec: text(item.rec),
42        at: typeof item.at === 'number' ? item.at : null,
43      },
44    ]
45  })
46
47  return {
48    sessionId: file.sessionId,
49    name: text(file.name),
50    project: text(file.project),
51    updatedAt: typeof file.updatedAt === 'number' ? file.updatedAt : 0,
52    questions,
53  }
54}
55
56/**
57 * The sessions worth showing: not this one, not stale, with a question open. The session with the oldest question
58 * comes first, since its question has waited longest.
59 */
60export function openSessions(all: SessionStatus[], now: number, ownId: string): SessionStatus[] {
61  const oldest = (s: SessionStatus) => Math.min(...s.questions.map(q => q.at ?? s.updatedAt))
62
63  return all
64    .filter(s => s.sessionId !== ownId && s.questions.length > 0 && now - s.updatedAt <= STALE_MS)
65    .sort((a, b) => oldest(a) - oldest(b))
66}
67
68/** What a session reads when the person answers from the consolidated inbox. Same form as the inbox's own answers. */
69export const answerText = (ask: string, answer: string): string =>
70  `The person answered this from the consolidated inbox. Re "${ask}": ${answer}`
71
72/**
73 * The prompt that has this session's Claude send the answer on. It runs when `session.send` is refused, as in auto
74 * mode, which judges a model's SendMessage call but has no request to judge for a mod's.
75 */
76export const relayText = (sessionId: string, questionId: string, answer: string): string =>
77  `The person answered a question of another session from the consolidated inbox. Send the message below to session ${sessionId} with the SendMessage tool, word for word, and do nothing else.\n\nThe person answered this from the consolidated inbox. Re question ${questionId}: ${answer}`
78
79/** The label for a session: its name, else its folder's name, else the start of its id. */
80export const sessionLabel = (s: SessionStatus): string =>
81  s.name ?? s.project?.split('/').filter(Boolean).at(-1) ?? s.sessionId.slice(0, 8)
82
types/index.d.ts 25 lines
1export type Question = { id: string; ask: string; options: string[]; rec: string | null; at: number | null }
2
3export type SessionStatus = {
4  sessionId: string
5  name: string | null
6  project: string | null
7  updatedAt: number
8  questions: Question[]
9}
10
11export type Sent = { is: 'sent' | 'relayed' | 'failed'; reason?: string }
12
13declare module 'claude-code' {
14  interface PluginState {
15    'consolidated-inbox': {
16      /** The sessions with open questions, oldest question first. */
17      sessions: SessionStatus[]
18      /** What happened to each answer, by `<session id>:<question id>`. */
19      sent: Record<string, Sent>
20      /** The `<session id>:<question id>` whose free-text field is open, if any. */
21      typing: string | null
22    }
23  }
24}
25