diff --git a/app/api/realtime/solo/route.ts b/app/api/realtime/solo/route.ts
index e514797..dbf820b 100644
--- a/app/api/realtime/solo/route.ts
+++ b/app/api/realtime/solo/route.ts
@@ -64,7 +64,17 @@ type WorkerPayload = {
product_id?: string
}
-type NotifyPayload = EntityPayload | JobPayload | WorkerPayload
+// M13: per-iteration quota-rapport van de worker. Geen product-scope —
+// elke heartbeat geldt voor alle producten waar deze user toegang toe heeft.
+type WorkerHeartbeatPayload = {
+ type: 'worker_heartbeat'
+ user_id: string
+ token_id: string
+ last_quota_pct: number
+ last_quota_check_at: string
+}
+
+type NotifyPayload = EntityPayload | JobPayload | WorkerPayload | WorkerHeartbeatPayload
function isJobPayload(p: NotifyPayload): p is JobPayload {
return 'type' in p && (p.type === 'claude_job_enqueued' || p.type === 'claude_job_status')
@@ -74,6 +84,10 @@ function isWorkerPayload(p: NotifyPayload): p is WorkerPayload {
return 'type' in p && (p.type === 'worker_connected' || p.type === 'worker_disconnected')
}
+function isWorkerHeartbeatPayload(p: NotifyPayload): p is WorkerHeartbeatPayload {
+ return 'type' in p && p.type === 'worker_heartbeat'
+}
+
function shouldEmit(
payload: NotifyPayload,
productId: string,
@@ -90,6 +104,10 @@ function shouldEmit(
return payload.user_id === userId
}
+ if (isWorkerHeartbeatPayload(payload)) {
+ return payload.user_id === userId
+ }
+
// M11 (ST-1104): question-events horen op /api/realtime/notifications, niet hier.
if (payload.entity === 'question') return false
diff --git a/components/shared/nav-bar.tsx b/components/shared/nav-bar.tsx
index bd93d68..42f0e2c 100644
--- a/components/shared/nav-bar.tsx
+++ b/components/shared/nav-bar.tsx
@@ -30,6 +30,7 @@ interface NavBarProps {
activeProduct: { id: string; name: string } | null
products: { id: string; name: string }[]
hasActiveSprint: boolean
+ minQuotaPct: number
}
export function NavBar({
@@ -41,6 +42,7 @@ export function NavBar({
activeProduct,
products,
hasActiveSprint,
+ minQuotaPct,
}: NavBarProps) {
const pathname = usePathname()
const router = useRouter()
@@ -188,7 +190,7 @@ export function NavBar({
{/* Rechts: solo-status + notifications + account-menu */}
-
+
diff --git a/components/solo/nav-status-indicators.tsx b/components/solo/nav-status-indicators.tsx
index e370540..28f9288 100644
--- a/components/solo/nav-status-indicators.tsx
+++ b/components/solo/nav-status-indicators.tsx
@@ -40,13 +40,28 @@ function RealtimeIndicator({
)
}
-export function SoloNavStatusIndicators({ hasActiveProduct }: { hasActiveProduct: boolean }) {
+export function SoloNavStatusIndicators({
+ hasActiveProduct,
+ minQuotaPct,
+}: {
+ hasActiveProduct: boolean
+ minQuotaPct: number
+}) {
const realtimeStatus = useSoloStore((s) => s.realtimeStatus)
const showConnectingIndicator = useSoloStore((s) => s.showConnectingIndicator)
const connectedWorkers = useSoloStore((s) => s.connectedWorkers)
+ const workerQuotaPct = useSoloStore((s) => s.workerQuotaPct)
if (!hasActiveProduct) return null
+ // M13: stand-by als alle workers low quota hebben (workerQuotaPct geldt
+ // voor de laatste-rapporterende worker; bij N>1 workers is dit een
+ // benadering — server-side aggregate is een v2-verbetering).
+ const isStandby =
+ connectedWorkers > 0 &&
+ workerQuotaPct !== null &&
+ workerQuotaPct < minQuotaPct
+
return (
0 ? 'bg-status-done' : 'bg-muted-foreground/40'
+ isStandby
+ ? 'bg-warning'
+ : connectedWorkers > 0
+ ? 'bg-status-done'
+ : 'bg-muted-foreground/40'
)} />
- {connectedWorkers > 0 ? 'Agent verbonden' : 'Geen agent'}
+ {isStandby ? (
+
+
+ Stand-by ({workerQuotaPct}%)}
+ />
+
+ Worker wacht tot Anthropic-quota stijgt boven {minQuotaPct}%
+
+
+
+ ) : connectedWorkers > 0 ? (
+ 'Agent verbonden'
+ ) : (
+ 'Geen agent'
+ )}
)
diff --git a/docs/INDEX.md b/docs/INDEX.md
index 9dd6afc..d156bcd 100644
--- a/docs/INDEX.md
+++ b/docs/INDEX.md
@@ -2,7 +2,7 @@
# Documentation Index
-Auto-generated on 2026-05-05 from front-matter and headings.
+Auto-generated on 2026-05-06 from front-matter and headings.
## Architecture Decision Records
diff --git a/docs/runbooks/mcp-integration.md b/docs/runbooks/mcp-integration.md
index 61e5f39..865016a 100644
--- a/docs/runbooks/mcp-integration.md
+++ b/docs/runbooks/mcp-integration.md
@@ -70,6 +70,43 @@ Wanneer je als agent draait (na een instructie als *"pak de volgende job uit de
Dit blijft gelden als je tussen jobs door commits, branches of pushes hebt gedaan — die afsluiting hoort bij de individuele job, niet bij het einde van de batch.
+## Pre-flight quota-check (M13)
+
+Vóór elke `wait_for_job`-aanroep doet de worker een pre-flight quota-check
+om te voorkomen dat-ie 600 s blokkeert terwijl Anthropic-quota toch op
+0 staat. Loop:
+
+1. `mcp__scrum4me__get_worker_settings()` → `{ min_quota_pct }`
+2. `bash bin/worker-quota-probe.sh` → JSON `{ pct, reset_at_iso, ... }`
+3. `mcp__scrum4me__worker_heartbeat({ last_quota_pct: pct, last_quota_check_at })`
+ — server emit een SSE-event zodat NavBar realtime de stand-by-badge
+ kan tonen
+4. **Als `pct < min_quota_pct`**: log "stand-by, wachten tot
+ `reset_at_iso`", sleep tot reset (cap op 1 uur), spring naar stap 2
+5. **Anders**: ga door met `wait_for_job`
+
+Pseudo-bash:
+
+```bash
+QUOTA_JSON=$(/opt/agent/bin/worker-quota-probe.sh)
+PCT=$(echo "$QUOTA_JSON" | jq -r '.pct')
+RESET=$(echo "$QUOTA_JSON" | jq -r '.reset_at_iso')
+
+# Stuur naar server (best-effort; failure niet-fataal)
+mcp_call worker_heartbeat "{\"last_quota_pct\": $PCT}"
+
+if [[ "$PCT" -lt "$MIN_PCT" ]]; then
+ log "stand-by until $RESET (pct=$PCT < min=$MIN_PCT)"
+ sleep_until "$RESET"
+ continue
+fi
+```
+
+**Beperking**: de probe kost ~1 outputtoken per check. 12 checks/uur =
+12 tokens/uur overhead — verwaarloosbaar. De `min_quota_pct`-setting
+staat per default op 20% — bij vrije Pro/Max-plans typisch ruim genoeg
+om dagelijks werk niet te verstoren.
+
**Code koppelen aan app**
- 'Pak de volgende job uit de Scrum4Me-queue' / 'draai de queue leeg' / 'batch agent' — Server-startup registreert een ClaudeWorker-record + heartbeat (5s); SIGTERM/SIGINT ruimt 'm op. UI in NavBar telt actieve workers via `last_seen_at < now() - 15s`.
diff --git a/lib/realtime/use-solo-realtime.ts b/lib/realtime/use-solo-realtime.ts
index 928dd80..cf93361 100644
--- a/lib/realtime/use-solo-realtime.ts
+++ b/lib/realtime/use-solo-realtime.ts
@@ -40,6 +40,7 @@ export function useSoloRealtime(productId: string | null) {
const setWorkers = useSoloStore.getState().setWorkers
const incrementWorkers = useSoloStore.getState().incrementWorkers
const decrementWorkers = useSoloStore.getState().decrementWorkers
+ const setWorkerQuota = useSoloStore.getState().setWorkerQuota
if (!productId) {
// Geen actief product (gebruiker zit niet op /solo) — stream uit
@@ -119,6 +120,15 @@ export function useSoloRealtime(productId: string | null) {
}
if (raw.type === 'worker_connected') { incrementWorkers(); return }
if (raw.type === 'worker_disconnected') { decrementWorkers(); return }
+ if (raw.type === 'worker_heartbeat') {
+ const hb = raw as {
+ type: 'worker_heartbeat'
+ last_quota_pct: number
+ last_quota_check_at: string
+ }
+ setWorkerQuota(hb.last_quota_pct, hb.last_quota_check_at)
+ return
+ }
return
}
const payload = raw as RealtimeEvent
diff --git a/stores/solo-store.ts b/stores/solo-store.ts
index 592976e..5fa6e7c 100644
--- a/stores/solo-store.ts
+++ b/stores/solo-store.ts
@@ -64,6 +64,11 @@ interface SoloStore {
claudeJobsByTaskId: Record
connectedWorkers: number
+ // M13: laatste quota-rapport van een actieve worker. null = geen
+ // worker actief of nog geen heartbeat met quota ontvangen.
+ workerQuotaPct: number | null
+ workerQuotaCheckAt: string | null
+
initTasks: (tasks: SoloTask[]) => void
optimisticMove: (taskId: string, toStatus: TaskStatus) => TaskStatus | null
rollback: (taskId: string, prevStatus: TaskStatus) => void
@@ -82,6 +87,7 @@ interface SoloStore {
setWorkers: (count: number) => void
incrementWorkers: () => void
decrementWorkers: () => void
+ setWorkerQuota: (pct: number, checkAt: string) => void
handleRealtimeEvent: (event: RealtimeEvent) => void
}
@@ -93,6 +99,8 @@ export const useSoloStore = create((set, get) => ({
showConnectingIndicator: false,
claudeJobsByTaskId: {},
connectedWorkers: 0,
+ workerQuotaPct: null,
+ workerQuotaCheckAt: null,
initTasks: (tasks) =>
set({ tasks: Object.fromEntries(tasks.map(t => [t.id, t])) }),
@@ -145,7 +153,15 @@ export const useSoloStore = create((set, get) => ({
setWorkers: (count) => set({ connectedWorkers: Math.max(0, count) }),
incrementWorkers: () => set(s => ({ connectedWorkers: s.connectedWorkers + 1 })),
- decrementWorkers: () => set(s => ({ connectedWorkers: Math.max(0, s.connectedWorkers - 1) })),
+ decrementWorkers: () =>
+ set((s) => ({
+ connectedWorkers: Math.max(0, s.connectedWorkers - 1),
+ // Reset quota-state als alle workers weg zijn — pct van een vertrokken
+ // worker is niet meer actueel.
+ workerQuotaPct: s.connectedWorkers - 1 <= 0 ? null : s.workerQuotaPct,
+ workerQuotaCheckAt: s.connectedWorkers - 1 <= 0 ? null : s.workerQuotaCheckAt,
+ })),
+ setWorkerQuota: (pct, checkAt) => set({ workerQuotaPct: pct, workerQuotaCheckAt: checkAt }),
handleJobEvent: (event) => {
const { job_id, task_id } = event