SLOPSHOPPER

agent-core-sessions

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

newpanecommandtimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-core-sessions
│ ┃ Sitzungen ✕ › fix the failing auth test and add an audit log call │ ┃ 0 Sitzungen · gelesen vor 0s │ ┃ ⏺ Read(src/auth.ts) │ ┃ Eigene Sitzung: Kontext 49 % · Agenten 0 · ⎿ Read 6 lines │ ┃ 0.42 USD ⏺ Update(src/auth.ts) │ ┃ Sitzungsregister nicht gefunden — diese ⎿ Added 2 lines, removed 1 line │ ┃ Anzeige kennt nur die eigene Sitzung. ⏺ 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 │ │ › /agent-core-sessions │ ⎿ agent-core-sessions: Pane "Sitzungen" geöffnet. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Sitzungen
0 Sitzungen · gelesen vor 0s Eigene Sitzung: Kontext 49 % · Agenten 0 · 0.42 USD Sitzungsregister nicht gefunden — diese Anzeige kennt nur die eigene Sitzung.
README

{{PROJECT_NAME}}

{{PROJECT_DESC}}

Tech-Stack

BereichTechnologie
WebReact 19 + TypeScript + Vite + TanStack Router/Query
ArchitekturFeature-Sliced Design (FSD)
APIHono + Prisma + PostgreSQL
MobileReact Native + Expo (Platzhalter)
MonorepoTurborepo + pnpm Workspaces
AI DevClaude Code Agent Teams
CI/CDGitHub Actions + Jenkins
QualitätESLint Boundaries + jscpd + DRYwall

Schnellstart

pnpm install
cp .env.example .env
docker compose up db -d
pnpm dev

Projektstruktur

├── 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

Agent Workflow

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-Server (vorkonfiguriert)

MCPNutzen
GitHubIssues, PRs, Branches direkt steuern
PrismaMigrationen, Schema, Studio
Context7Aktuelle Library-Doku abrufen
PlaywrightBrowser-Tests ausführen

Config: .claude/.mcp.json — GITHUB_TOKEN in .env setzen.

VS Code Workspaces

Jedes Team hat einen eigenen farbcodierten Workspace:

WorkspaceFarbeFür
team.code-workspaceGrauLead / Übersicht
frontend.code-workspace🔵 BlauFrontend-Entwicklung
backend.code-workspace🟢 GrünBackend-Entwicklung
qa.code-workspace🟠 OrangeQualitätssicherung
designer.code-workspace🩷 PinkUI/UX Design
po.code-workspace🟣 LilaProduct Owner

Lizenz

MIT

Source 3 files
hooks/register.tsx 289 lines
1/**
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};
289
hooks/registry.ts 169 lines
1/**
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}
169
types/index.d.ts 68 lines
1/**
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