diff --git a/app/api/realtime/solo/route.ts b/app/api/realtime/solo/route.ts
index e514797..2babf2f 100644
--- a/app/api/realtime/solo/route.ts
+++ b/app/api/realtime/solo/route.ts
@@ -64,7 +64,15 @@ type WorkerPayload = {
product_id?: string
}
-type NotifyPayload = EntityPayload | JobPayload | WorkerPayload
+type WorkerHeartbeatPayload = {
+ type: 'worker_heartbeat'
+ user_id: string
+ token_id: string
+ last_quota_pct: number | null
+ is_low: boolean
+}
+
+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 +82,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 +102,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/solo/nav-status-indicators.tsx b/components/solo/nav-status-indicators.tsx
index e370540..8378659 100644
--- a/components/solo/nav-status-indicators.tsx
+++ b/components/solo/nav-status-indicators.tsx
@@ -44,22 +44,42 @@ export function SoloNavStatusIndicators({ hasActiveProduct }: { hasActiveProduct
const realtimeStatus = useSoloStore((s) => s.realtimeStatus)
const showConnectingIndicator = useSoloStore((s) => s.showConnectingIndicator)
const connectedWorkers = useSoloStore((s) => s.connectedWorkers)
+ const lowQuotaTokenIds = useSoloStore((s) => s.lowQuotaTokenIds)
if (!hasActiveProduct) return null
+ const allStandby = connectedWorkers > 0 && lowQuotaTokenIds.size >= connectedWorkers
+
return (
-
- 0 ? 'bg-status-done' : 'bg-muted-foreground/40'
- )} />
- {connectedWorkers > 0 ? 'Agent verbonden' : 'Geen agent'}
-
+
+
+
+ }>
+
+ {connectedWorkers === 0
+ ? 'Geen agent'
+ : allStandby
+ ? 'Worker stand-by'
+ : 'Agent verbonden'}
+
+ {allStandby && (
+ Worker wacht op quota-reset
+ )}
+
+
)
}
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..114e8f5 100644
--- a/docs/runbooks/mcp-integration.md
+++ b/docs/runbooks/mcp-integration.md
@@ -34,6 +34,10 @@ Scrum4Me heeft een eigen MCP-server in repo [`madhura68/scrum4me-mcp`](https://g
- `mcp__scrum4me__list_open_questions` — eigen vragen, max 50, recente eerst
- `mcp__scrum4me__cancel_question` — asker-only annulering van een eigen open vraag
+**Worker-instellingen + quota (M13):**
+- `mcp__scrum4me__get_worker_settings` — geen args; retourneert `{ min_quota_pct }` afgeleid van het Bearer-token. Gebruik dit voor de pre-flight quota-check.
+- `mcp__scrum4me__worker_heartbeat` — optionele args: `last_quota_pct` (0-100), `last_quota_check_at` (ISO-8601), `is_low` (boolean). Slaat quota-state op in `ClaudeWorker` en triggert een `worker_heartbeat` SSE-event zodat de NavBar een stand-by badge kan tonen.
+
**Job queue — agent worker mode (M13):**
- `mcp__scrum4me__wait_for_job` — blokkeert ≤600s, claimt atomisch een QUEUED-job via FOR UPDATE SKIP LOCKED. **Sinds M12** retourneert de payload een `kind`-discriminator:
- `kind: 'TASK_IMPLEMENTATION'` (default) — payload met `implementation_plan`, `story`, `pbi`, `sprint`, `repo_url`
@@ -73,6 +77,49 @@ Dit blijft gelden als je tussen jobs door commits, branches of pushes hebt gedaa
**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`.
+## Pre-flight quota-check
+
+Vóór elke `wait_for_job` kan de worker controleren of er voldoende Claude-quota is. Stappenplan:
+
+1. Haal de drempel op: `get_worker_settings` → `min_quota_pct` (bijv. 20).
+2. Probe de Claude API (rate-limit-headers):
+
+```bash
+RESPONSE=$(curl -sI https://api.anthropic.com/v1/messages \
+ -H "x-api-key: $ANTHROPIC_API_KEY" \
+ -H "anthropic-version: 2023-06-01")
+
+REMAINING=$(echo "$RESPONSE" | grep -i "anthropic-ratelimit-tokens-remaining" | awk '{print $2}' | tr -d '\r')
+LIMIT=$(echo "$RESPONSE" | grep -i "anthropic-ratelimit-tokens-limit" | awk '{print $2}' | tr -d '\r')
+RESET_EPOCH=$(echo "$RESPONSE" | grep -i "anthropic-ratelimit-tokens-reset" | awk '{print $2}' | tr -d '\r')
+
+QUOTA_PCT=$(( REMAINING * 100 / LIMIT ))
+```
+
+3. Vergelijk met drempel. Bij low quota: rapporteer en slaap:
+
+```bash
+if [ "$QUOTA_PCT" -lt "$MIN_QUOTA_PCT" ]; then
+ RESET_MS=$(date -d "@$RESET_EPOCH" +%s%3N 2>/dev/null || echo 0)
+ NOW_MS=$(date +%s%3N)
+ SLEEP_S=$(( (RESET_MS - NOW_MS) / 1000 + 5 ))
+ echo "Wachten tot quota-reset om $(date -d "@$RESET_EPOCH" '+%H:%M')"
+ sleep "$SLEEP_S"
+fi
+```
+
+4. Meld de quota-state via `worker_heartbeat`:
+
+```
+worker_heartbeat(
+ last_quota_pct=15,
+ last_quota_check_at="2026-05-06T12:00:00Z",
+ is_low=true
+)
+```
+
+5. Na de sleep: opnieuw controleren (stap 2) voordat `wait_for_job` wordt aangeroepen.
+
## Prompt
- `implement_next_story` (arg: `product_id`) — end-to-end workflow
diff --git a/lib/realtime/use-solo-realtime.ts b/lib/realtime/use-solo-realtime.ts
index 928dd80..105187c 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 setWorkerLowQuota = useSoloStore.getState().setWorkerLowQuota
if (!productId) {
// Geen actief product (gebruiker zit niet op /solo) — stream uit
@@ -118,7 +119,17 @@ export function useSoloRealtime(productId: string | null) {
return
}
if (raw.type === 'worker_connected') { incrementWorkers(); return }
- if (raw.type === 'worker_disconnected') { decrementWorkers(); return }
+ if (raw.type === 'worker_disconnected') {
+ const disconnected = raw as { type: string; token_id?: string }
+ if (disconnected.token_id) setWorkerLowQuota(disconnected.token_id, false)
+ decrementWorkers()
+ return
+ }
+ if (raw.type === 'worker_heartbeat') {
+ const hb = raw as { type: string; token_id: string; is_low: boolean }
+ setWorkerLowQuota(hb.token_id, hb.is_low)
+ return
+ }
return
}
const payload = raw as RealtimeEvent
diff --git a/stores/solo-store.ts b/stores/solo-store.ts
index 592976e..377ef85 100644
--- a/stores/solo-store.ts
+++ b/stores/solo-store.ts
@@ -63,6 +63,7 @@ interface SoloStore {
claudeJobsByTaskId: Record
connectedWorkers: number
+ lowQuotaTokenIds: Set
initTasks: (tasks: SoloTask[]) => void
optimisticMove: (taskId: string, toStatus: TaskStatus) => TaskStatus | null
@@ -82,6 +83,7 @@ interface SoloStore {
setWorkers: (count: number) => void
incrementWorkers: () => void
decrementWorkers: () => void
+ setWorkerLowQuota: (tokenId: string, isLow: boolean) => void
handleRealtimeEvent: (event: RealtimeEvent) => void
}
@@ -93,6 +95,7 @@ export const useSoloStore = create((set, get) => ({
showConnectingIndicator: false,
claudeJobsByTaskId: {},
connectedWorkers: 0,
+ lowQuotaTokenIds: new Set(),
initTasks: (tasks) =>
set({ tasks: Object.fromEntries(tasks.map(t => [t.id, t])) }),
@@ -146,6 +149,13 @@ 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) })),
+ setWorkerLowQuota: (tokenId, isLow) =>
+ set(s => {
+ const next = new Set(s.lowQuotaTokenIds)
+ if (isLow) next.add(tokenId)
+ else next.delete(tokenId)
+ return { lowQuotaTokenIds: next }
+ }),
handleJobEvent: (event) => {
const { job_id, task_id } = event