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…

A collection of skills by hardkoded packaged as plugins for Claude, Cursor, GitHub Copilot, and Codex.
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./plugin marketplace add hardkoded/hardkoded-skills
/plugin install hardkoded@hardkoded
Import this repository as a team marketplace, then install hardkoded from the Cursor plugin UI.
Use Chat: Install Plugin From Source and provide:
https://github.com/hardkoded/hardkoded-skills
codex plugin marketplace add hardkoded/hardkoded-skills
codex plugin add hardkoded@hardkoded
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).
MIT
hooks/register.tsx 216 lines1import { 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}
216hooks/status.ts 82 lines1import 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)
82types/index.d.ts 25 lines1export 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