Zeigt alle lokalen Claude-Code-Sitzungen in einer Pane: Name, Status, Arbeitsverzeichnis und Alter aus dem Sitzungsregister der Engine, dazu Kontextfüllung und…

{{PROJECT_DESC}}
| Bereich | Technologie |
|---|---|
| Web | React 19 + TypeScript + Vite + TanStack Router/Query |
| Architektur | Feature-Sliced Design (FSD) |
| API | Hono + Prisma + PostgreSQL |
| Mobile | React Native + Expo (Platzhalter) |
| Monorepo | Turborepo + pnpm Workspaces |
| AI Dev | Claude Code Agent Teams |
| CI/CD | GitHub Actions + Jenkins |
| Qualität | ESLint Boundaries + jscpd + DRYwall |
pnpm install
cp .env.example .env
docker compose up db -d
pnpm dev
├── apps/
│ ├── web/ ← React + FSD
│ ├── api/ ← Hono Backend
│ └── mobile/ ← React Native (Platzhalter)
├── packages/
│ ├── shared-types/ ← Zod Schemas + Types (Single Source of Truth)
│ ├── shared-ui/ ← Gemeinsame Komponenten
│ ├── shared-api/ ← API Client + Query Factories
│ └── config/ ← Shared Configs
├── .claude/agents/ ← Agent Team Definitionen
├── .scrum/ ← Scrum Board (Dateien)
├── docs/ ← ADRs, Guides
└── workspaces/ ← VS Code Team-Configs
claude # Claude Code starten
# Slash Commands (Skills):
/scrum # Sprint-Board anzeigen
/scrum create Login implementieren # Neuen Task anlegen
/new-feature User-Profil mit Avatar # Feature FSD-konform scaffolden
/review apps/web/src/features/ # Code Review nach Standards
/commit # Smart Commit mit Changelog
# Workflow-Stufen:
"Bugfix: [Problem]" # Stufe 1 — schnell, 1 Agent
"Lite Feature: [Was]" # Stufe 2 — Senior + QS
"Full Scrum: [Anforderung]" # Stufe 3 — volles Team
| MCP | Nutzen |
|---|---|
| GitHub | Issues, PRs, Branches direkt steuern |
| Prisma | Migrationen, Schema, Studio |
| Context7 | Aktuelle Library-Doku abrufen |
| Playwright | Browser-Tests ausführen |
Config: .claude/.mcp.json — GITHUB_TOKEN in .env setzen.
Jedes Team hat einen eigenen farbcodierten Workspace:
| Workspace | Farbe | Für |
|---|---|---|
team.code-workspace | Grau | Lead / Übersicht |
frontend.code-workspace | 🔵 Blau | Frontend-Entwicklung |
backend.code-workspace | 🟢 Grün | Backend-Entwicklung |
qa.code-workspace | 🟠 Orange | Qualitätssicherung |
designer.code-workspace | 🩷 Pink | UI/UX Design |
po.code-workspace | 🟣 Lila | Product Owner |
MIT
hooks/register.tsx 289 lines1/**
2 * `agent-core-sessions` — agent-core's own mod (Klasse E, Anzeige-Fläche).
3 *
4 * It draws one pane listing EVERY local Claude Code session: its name, what it last said it was
5 * doing, its working directory and how long ago it said so — plus, for the session the pane is drawn
6 * in, the context-window fill, the cost so far and how many agent loops are running.
7 *
8 * WHY THE SESSION REGISTRY AND NOT `$.store`. The reach asked for is "all local sessions", and
9 * `$.agent.list()` and `$.session.usage()` are both own-session only (measured against this build's
10 * own type declarations: AgentInfo is "One agent loop of this session"). The only cross-session
11 * source is the engine's own registry under `<config dir>/sessions/<pid>.json`, which the engine
12 * writes for every session whether this mod is loaded there or not — so a session that started
13 * before the mod existed is still on the board. Publishing a row per session through `$.store`
14 * would cover only sessions that already have the mod, and `$.store` is one JSON file per plugin
15 * shared by every session, so two sessions writing at once lose each other's row.
16 *
17 * WHAT IT DELIBERATELY DOES NOT DO (ADR-0057, Klasse E — the surrender is the condition of being
18 * shipped at all). No `tool.check` hook, and no `tool.call` hook of any kind, so this mod cannot
19 * decide a tool call and cannot outvote agent-core's own delegate-only PreToolUse guard. No
20 * `$.fs.write`, `$.process.*`, `$.env.set`, `$.prompt.submit`, `$.session.send`, `$.mcp.call`,
21 * `$.model.complete` and no `agent.spawn` — a mod that sets a model would override the per-call
22 * model parameter. `claude plugin validate` reads the `hooks:` and `calls:` lines out of this file
23 * without running it, so the surrender is checkable at the artifact.
24 */
25
26import { atom, read, update } from 'claude-code';
27import type { EngineInterface, Register } from 'claude-code';
28
29import type { Board, SessionRow } from '../types';
30
31import {
32 formatAge,
33 isSessionRecord,
34 isStale,
35 registryCandidates,
36 shortCwd,
37 sortRows,
38 toRow,
39} from './registry';
40
41/** The pane's id, and the slash command that reopens it after a close. */
42const PANE = 'agent-core-sessions';
43
44/** How often the board is re-read. The registry is a handful of small files; 5 s is cheap and live. */
45const REFRESH_MS = 5_000;
46
47/** After this long without a touch, a session's own status word is no longer treated as current. */
48const STALE_AFTER_MS = 60_000;
49
50/** Never read more than this many records, so a registry left full of dead pids cannot stall a draw. */
51const MAX_RECORDS = 64;
52
53const board = atom({ plugin: 'agent-core-sessions', key: 'board' } as const, null);
54
55/**
56 * Find the engine's session registry by probing upwards from the plugin's own folder.
57 *
58 * @param $ - the engine interface
59 * @returns the first existing `…/sessions` directory, or `null` when none of the candidates exists
60 */
61async function findRegistry($: EngineInterface): Promise<string | null> {
62 for (const candidate of registryCandidates($.plugin.root)) {
63 const exists = await $.fs.exists(candidate).catch(() => false);
64 if (exists) return candidate;
65 }
66 return null;
67}
68
69/**
70 * Read the registry and the own session's figures, and write the result into `$.state`.
71 *
72 * Writing the state is what redraws the pane — a `$.state.set` draws exactly the sites that read
73 * the value while drawing, so no `$.ui.invalidate` is needed. Every failure arrives as `note` or
74 * `skipped` rather than as an empty board: a board with no rows and no reason would read as "no
75 * sessions", which is the one thing this display must never claim by accident.
76 *
77 * @param $ - the engine interface
78 * @returns nothing; the board is handed over through `$.state`
79 */
80async function refresh($: EngineInterface): Promise<void> {
81 const readAt = await $.clock.now();
82 const ownSessionId = await $.session.id().catch(() => '');
83
84 let ownContextPercent: number | null = null;
85 let ownCostUsd: number | null = null;
86 let ownAgentsRunning = 0;
87 try {
88 // The field names are this build's own: SessionContextUsage is `{ tokens?, window, percent? }`
89 // and SessionCost is `{ usd }`. Guessing them (`used`/`size`/`totalCostUsd`) costs nothing at
90 // load or validate time and leaves the figure silently `null` forever — measured, and the reason
91 // these two reads are spelled against the declaration rather than from memory.
92 const usage = await $.session.usage();
93 const percent = usage.context?.percent;
94 const tokens = usage.context?.tokens;
95 const window = usage.context?.window;
96 if (typeof percent === 'number') {
97 ownContextPercent = Math.round(percent);
98 } else if (typeof tokens === 'number' && typeof window === 'number' && window > 0) {
99 ownContextPercent = Math.round((tokens / window) * 100);
100 }
101 if (typeof usage.cost?.usd === 'number') ownCostUsd = usage.cost.usd;
102 } catch {
103 /* the engine reports no figures for this session — the row simply carries none */
104 }
105 try {
106 ownAgentsRunning = (await $.agent.list()).filter((a) => a.status === 'running').length;
107 } catch {
108 /* no agent list available — 0 is then "not reported", which `note` does not claim otherwise */
109 }
110
111 const registryDir = await findRegistry($);
112 if (registryDir === null) {
113 await update($, board, () => ({
114 rows: [],
115 registryDir: null,
116 readAt,
117 skipped: 0,
118 note: 'Sitzungsregister nicht gefunden — diese Anzeige kennt nur die eigene Sitzung.',
119 ownContextPercent,
120 ownCostUsd,
121 ownAgentsRunning,
122 }));
123 return;
124 }
125
126 let entries: readonly { name: string }[] = [];
127 try {
128 entries = await $.fs.list(registryDir);
129 } catch (error) {
130 await update($, board, () => ({
131 rows: [],
132 registryDir,
133 readAt,
134 skipped: 0,
135 note: `Sitzungsregister nicht lesbar: ${String(error)}`,
136 ownContextPercent,
137 ownCostUsd,
138 ownAgentsRunning,
139 }));
140 return;
141 }
142
143 // `<pid>.<hash>.key` files live in the same directory and carry a peer token. Only `<pid>.json`
144 // passes this test, so no credential is ever read into the plugin's environment.
145 const records = entries.map((entry) => entry.name).filter(isSessionRecord);
146 const budget = records.slice(0, MAX_RECORDS);
147 const rows: SessionRow[] = [];
148 let skipped = records.length - budget.length;
149
150 for (const name of budget) {
151 const text = await $.fs.read(`${registryDir}/${name}`).catch(() => undefined);
152 if (typeof text !== 'string') {
153 skipped += 1;
154 continue;
155 }
156 let parsed: unknown;
157 try {
158 parsed = JSON.parse(text);
159 } catch {
160 skipped += 1;
161 continue;
162 }
163 const row = toRow(parsed, ownSessionId);
164 if (row === null) skipped += 1;
165 else rows.push(row);
166 }
167
168 const board_: Board = {
169 rows: sortRows(rows),
170 registryDir,
171 readAt,
172 skipped,
173 note: skipped > 0 ? `${skipped} Satz/Sätze übersprungen (nicht lesbar oder ohne Kennung).` : null,
174 ownContextPercent,
175 ownCostUsd,
176 ownAgentsRunning,
177 };
178 await update($, board, () => board_);
179}
180
181export const register: Register = (on) => {
182 on('session.start', async ($, e, next) => {
183 await $.command.register({
184 name: PANE,
185 description: 'Die Pane mit allen lokalen Sitzungen öffnen',
186 });
187
188 // Opened unasked on purpose: the point of the board is to be there without being summoned. The
189 // surface decides whether it is seated — a pane opened unasked waits below ~110 columns instead
190 // of taking the room, and the slash command above brings it back after a close.
191 void $.ui.open({ id: PANE, title: 'Sitzungen' });
192
193 void refresh($);
194 $.clock.every(REFRESH_MS, () => {
195 void refresh($);
196 });
197
198 return next(e);
199 });
200
201 // `.catch` is mandatory, not decoration: `claude plugin validate` reports a gating hook without
202 // one ("gating hook without .catch"), and without it a failed hook is simply ABSENT — the person
203 // would type the command and get the engine's "unknown command" instead of a reason.
204 on('command.run', { command: PANE }, async ($) => {
205 await $.ui.open({ id: PANE, title: 'Sitzungen' });
206 await refresh($);
207
208 return { text: 'Pane "Sitzungen" geöffnet.' };
209 }).catch(($, e, next) =>
210 next.called ? next(e) : { text: `${$.plugin.name}: Die Pane konnte nicht geöffnet werden.` },
211 );
212
213 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
214 const { Box, Text } = $.ui.resolve(e);
215 const shown = await read($, board);
216 const now = await $.clock.now();
217
218 if (shown === null) {
219 return (
220 <Box flexDirection="column">
221 <Text color="subtle">Sitzungsregister wird gelesen …</Text>
222 </Box>
223 );
224 }
225
226 const columns = e.props.bodyColumns;
227 const room = Math.max(1, (e.props.scroll?.bodyRows ?? e.viewport?.rows ?? 24) - 3);
228 // The directory column gets whatever the fixed columns leave: name 18, status 9, age 5, gaps 3.
229 const cwdRoom = Math.max(8, columns - 35);
230 const visible = shown.rows.slice(0, room);
231
232 return (
233 <Box flexDirection="column">
234 <Text color="subtle">
235 {shown.rows.length === 1 ? '1 Sitzung' : `${shown.rows.length} Sitzungen`} · gelesen vor{' '}
236 {formatAge(now - shown.readAt)}
237 </Text>
238
239 {shown.rows.length === 0 && shown.note === null && (
240 <Text color="subtle">Keine Sitzung im Register.</Text>
241 )}
242
243 {visible.map((row) => {
244 const stale = isStale(row, now, STALE_AFTER_MS);
245 const status = stale ? `${row.status}?` : row.status;
246 return (
247 <Box key={`s:${row.pid}`} flexDirection="row" gap={1}>
248 <Text
249 color={row.isSelf ? 'claude' : 'text'}
250 bold={row.isSelf}
251 wrap="truncate-end"
252 key={`n:${row.pid}`}
253 >
254 {(row.isSelf ? `▸ ${row.name}` : ` ${row.name}`).padEnd(18).slice(0, 18)}
255 </Text>
256 <Text
257 color={stale ? 'warning' : row.status === 'busy' ? 'success' : 'subtle'}
258 key={`st:${row.pid}`}
259 >
260 {status.padEnd(9).slice(0, 9)}
261 </Text>
262 <Text color="subtle" key={`c:${row.pid}`}>
263 {shortCwd(row.cwd, cwdRoom).padEnd(cwdRoom).slice(0, cwdRoom)}
264 </Text>
265 <Text color="inactive" key={`a:${row.pid}`}>
266 {formatAge(now - row.updatedAt).padStart(5)}
267 </Text>
268 </Box>
269 );
270 })}
271
272 {shown.rows.length > visible.length && (
273 <Text color="subtle">… {shown.rows.length - visible.length} weitere</Text>
274 )}
275
276 <Box flexDirection="column" marginTop={1}>
277 <Text color="subtle">
278 Eigene Sitzung: Kontext{' '}
279 {shown.ownContextPercent === null ? '—' : `${shown.ownContextPercent} %`} · Agenten{' '}
280 {shown.ownAgentsRunning}
281 {shown.ownCostUsd === null ? '' : ` · ${shown.ownCostUsd.toFixed(2)} USD`}
282 </Text>
283 {shown.note !== null && <Text color="warning">{shown.note}</Text>}
284 </Box>
285 </Box>
286 );
287 });
288};
289hooks/registry.ts 169 lines1/**
2 * Pure helpers of the `agent-core-sessions` mod: reading the engine's session registry is the only
3 * thing this mod does, and everything here is the part of it that needs no `$`.
4 *
5 * Deliberately free of every engine call. `claude plugin validate` reports a module's `calls:` line
6 * from its source, and keeping the `$` calls in `register.tsx` alone makes that line the whole truth
7 * about what this mod reaches — a reviewer does not have to chase a helper to be sure.
8 */
9
10import type { SessionRow } from '../types';
11
12/**
13 * How many levels above the plugin's own folder are probed for a `sessions/` registry.
14 *
15 * Four, because that is what the two measured layouts need and no more: delivered at
16 * `<config dir>/agent-core-mods/plugins/<name>/` the configuration directory is three up, and a
17 * plugin read out of `<config dir>/plugins/<something>/<name>/` is four. The bound matters in both
18 * directions — too few never finds it, too many could hit an unrelated `sessions` directory further
19 * up, and the probe takes the NEAREST hit.
20 */
21const MAX_WALK_UP = 4;
22
23/**
24 * Split a path on either separator, so one helper serves Windows and POSIX.
25 *
26 * @param path - an absolute or relative path in native or POSIX spelling
27 * @returns its non-empty segments, plus a leading `''` for a POSIX root
28 */
29function segmentsOf(path: string): string[] {
30 return path.split(/[\\/]/);
31}
32
33/**
34 * The candidate configuration directories to probe for a `sessions/` registry, nearest first.
35 *
36 * Derived from the plugin's own folder rather than from `$.env` or a pinned path: a delivered mod
37 * sits at `<config dir>/skills/<name>/`, so the configuration directory is two levels up — but the
38 * mod must also behave when it is loaded from somewhere else (a `--plugin-dir` folder while it is
39 * being written). Walking up and probing each level with an existence check decides by measurement
40 * instead of by a name, and works for a configuration directory that is not called `.claude`.
41 *
42 * ADR-0050: no machine-bound path may be pinned into a shipped artifact, so this is computed at
43 * runtime, every time, from what the engine reports as the plugin's own root.
44 *
45 * @param pluginRoot - `$.plugin.root`, the absolute folder holding `plugin.json`
46 * @returns the directories to probe, nearest first; empty when `pluginRoot` has no segments
47 */
48export function registryCandidates(pluginRoot: string): string[] {
49 const parts = segmentsOf(pluginRoot).filter((s, i) => s !== '' || i === 0);
50 const sep = pluginRoot.includes('\\') ? '\\' : '/';
51 const out: string[] = [];
52 for (let up = 1; up <= MAX_WALK_UP && parts.length - up >= 1; up += 1) {
53 out.push(`${parts.slice(0, parts.length - up).join(sep)}${sep}sessions`);
54 }
55 return out;
56}
57
58/**
59 * Whether a registry directory entry is a session record this mod may read.
60 *
61 * Strictly `<digits>.json`. The same directory also holds `<pid>.<hash>.key` files, which carry a
62 * peer token: this mod never reads one, and the shape of this test — not a `.key` exclusion — is
63 * what keeps it that way when a further file kind appears there.
64 *
65 * @param name - a bare directory entry name
66 * @returns whether it is a `<pid>.json` session record
67 */
68export function isSessionRecord(name: string): boolean {
69 return /^[0-9]+\.json$/.test(name);
70}
71
72/** A number from an unknown field, or `undefined` when it is not a finite number. */
73function numberOf(value: unknown): number | undefined {
74 return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
75}
76
77/** A non-empty string from an unknown field, or `undefined`. */
78function stringOf(value: unknown): string | undefined {
79 return typeof value === 'string' && value !== '' ? value : undefined;
80}
81
82/**
83 * Turn one parsed session record into a row, or `null` when it carries no usable identity.
84 *
85 * Every field is defaulted rather than assumed: the registry is the engine's own file and its shape
86 * may grow, so a record missing a field still shows as a session instead of dropping out of the
87 * board unseen. A record with neither `pid` nor `sessionId` is the one case that cannot be drawn.
88 *
89 * @param raw - the parsed JSON of one `<pid>.json`
90 * @param ownSessionId - `$.session.id()` of the session this mod instance runs in
91 * @returns the row, or `null` when the record has no identity
92 */
93export function toRow(raw: unknown, ownSessionId: string): SessionRow | null {
94 if (raw === null || typeof raw !== 'object') return null;
95 const r = raw as Record<string, unknown>;
96 const pid = numberOf(r.pid);
97 const sessionId = stringOf(r.sessionId);
98 if (pid === undefined && sessionId === undefined) return null;
99 const startedAt = numberOf(r.startedAt) ?? 0;
100 return {
101 pid: pid ?? 0,
102 sessionId: sessionId ?? '',
103 name: stringOf(r.name) ?? (pid === undefined ? '?' : `pid ${pid}`),
104 cwd: stringOf(r.cwd) ?? '',
105 status: stringOf(r.status) ?? '?',
106 kind: stringOf(r.kind) ?? '?',
107 version: stringOf(r.version) ?? '?',
108 startedAt,
109 updatedAt: numberOf(r.updatedAt) ?? startedAt,
110 isSelf: sessionId !== undefined && sessionId === ownSessionId,
111 };
112}
113
114/**
115 * Order the board: the own session first, then the most recently active.
116 *
117 * @param rows - the rows as they were read
118 * @returns a new array, own session first, then by `updatedAt` descending
119 */
120export function sortRows(rows: readonly SessionRow[]): SessionRow[] {
121 return [...rows].sort((a, b) => {
122 if (a.isSelf !== b.isSelf) return a.isSelf ? -1 : 1;
123 return b.updatedAt - a.updatedAt;
124 });
125}
126
127/**
128 * A compact age, in unit symbols rather than words so the column stays narrow in any language.
129 *
130 * @param ms - an age in milliseconds; a negative value is treated as 0
131 * @returns `12s`, `7m`, `3h`, `5d`
132 */
133export function formatAge(ms: number): string {
134 const s = Math.max(0, Math.floor(ms / 1000));
135 if (s < 60) return `${s}s`;
136 if (s < 3600) return `${Math.floor(s / 60)}m`;
137 if (s < 86400) return `${Math.floor(s / 3600)}h`;
138 return `${Math.floor(s / 86400)}d`;
139}
140
141/**
142 * The tail of a path, cut to `max` characters with a leading ellipsis, for a narrow pane.
143 *
144 * @param cwd - the working directory as the registry spelled it
145 * @param max - how many characters the column has
146 * @returns the last `max` characters, `…`-led when something was cut; `—` for an empty path
147 */
148export function shortCwd(cwd: string, max: number): string {
149 if (cwd === '') return '—';
150 if (cwd.length <= max) return cwd;
151 return `…${cwd.slice(cwd.length - Math.max(1, max - 1))}`;
152}
153
154/**
155 * Whether a row's own record looks stale: it says `busy` but has not been touched for a while.
156 *
157 * The registry's own doc property, measured: a session that is closed or dies leaves its last status
158 * word standing. So `busy` alone is not evidence that work is running — only `busy` plus a recent
159 * `updatedAt` is, and the pane marks the difference instead of repeating a word it cannot trust.
160 *
161 * @param row - the row to judge
162 * @param now - the current time in milliseconds
163 * @param staleAfterMs - how long an untouched record stays trustworthy
164 * @returns whether the row's status word is older than `staleAfterMs`
165 */
166export function isStale(row: SessionRow, now: number, staleAfterMs: number): boolean {
167 return now - row.updatedAt > staleAfterMs;
168}
169types/index.d.ts 68 lines1/**
2 * The `$.state` contract of the `agent-core-sessions` mod.
3 *
4 * `claude plugin validate` holds every `$.state` key the hooks module names to this file, so a key
5 * that is not declared here fails validation instead of failing silently at runtime.
6 */
7
8/**
9 * One local Claude Code session, as the engine's own session registry records it.
10 *
11 * Every field is read from `<config dir>/sessions/<pid>.json` — written by the engine for EVERY
12 * session, whether this mod is loaded there or not. That is why the registry is the source and not
13 * a row each session publishes itself: a session that started before the mod existed is still here.
14 */
15export type SessionRow = {
16 /** The session process's pid, which is also its registry file's basename. */
17 pid: number;
18 /** The engine's session id (`$.session.id()` in that session). */
19 sessionId: string;
20 /** The session's display name as the engine derived or the person set it; the pid when absent. */
21 name: string;
22 /** Its working directory, as the registry spells it (native separators). */
23 cwd: string;
24 /** The word the session last wrote: `busy`, `idle`, … — a dead process leaves its last one standing. */
25 status: string;
26 /** `interactive`, or whatever kind the registry records. */
27 kind: string;
28 /** The engine version that WROTE this record — not necessarily the newest binary on the machine. */
29 version: string;
30 /** When that session began, in `$.clock.now()` milliseconds. */
31 startedAt: number;
32 /** When that session last touched its record, in `$.clock.now()` milliseconds. */
33 updatedAt: number;
34 /** Whether this row is the session the mod instance runs in (matched by `sessionId`). */
35 isSelf: boolean;
36};
37
38/**
39 * What the pane draws: the rows, when they were read, and what could not be read.
40 *
41 * `note` is the fail-loud channel — a board with no rows and no note would be indistinguishable
42 * from "no sessions", so an unreadable registry says so in the pane instead of drawing empty.
43 */
44export type Board = {
45 /** The sessions found, newest-touched first. */
46 rows: SessionRow[];
47 /** Which directory the rows were read from; `null` when none was found. */
48 registryDir: string | null;
49 /** When the read ran, in `$.clock.now()` milliseconds. */
50 readAt: number;
51 /** How many registry files were present but unreadable or unparsable; part of the reach. */
52 skipped: number;
53 /** A human-readable reason the board is incomplete, or `null` when it is not. */
54 note: string | null;
55 /** The own session's context-window fill in percent, or `null` when the engine reports none. */
56 ownContextPercent: number | null;
57 /** The own session's cost so far in USD, or `null` where the host keeps no ledger. */
58 ownCostUsd: number | null;
59 /** How many agent loops the own session is running now (`$.agent.list()`, own session only). */
60 ownAgentsRunning: number;
61};
62
63declare module 'claude-code' {
64 interface PluginState {
65 'agent-core-sessions': { board: Board | null };
66 }
67}
68