Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,37 @@ const config = [
'react-hooks/immutability': 'off',
},
},
// P1.5 — Discourage bare `fetch('/api/...')`.
// The team must migrate to `apiFetch<T>()` from `@/lib/api-client` so that
// 401 / 403 / 5xx / network failures are handled uniformly.
// Phase 1 (this PR): warn level, allow incremental migration.
// Phase 3 (final cleanup): upgrade to error and forbid merges that introduce
// new bare fetch('/api/...') sites.
// Selector rationale:
// - covers single-quoted, double-quoted, and template-literal forms
// - filters by /api prefix so cross-origin / external fetches stay untouched
// - exempts api-client.ts itself (the one allowed implementer)
{
files: ['src/**/*.{ts,tsx,js,jsx}'],
ignores: ['src/lib/api-client.ts'],
rules: {
'no-restricted-syntax': [
'warn',
{
selector:
"CallExpression[callee.name='fetch'] > Literal[value=/^\\/api\\//]",
message:
"Use apiFetch<T>() from '@/lib/api-client' instead of bare fetch('/api/...'). It handles 401 redirect, 403/5xx typed errors, and network failures uniformly. See PR-api-client.md.",
},
{
selector:
"CallExpression[callee.name='fetch'] > TemplateLiteral.arguments:first-child[quasis.0.value.raw=/^\\/api\\//]",
message:
"Use apiFetch<T>() from '@/lib/api-client' instead of bare fetch(`/api/...`). It handles 401 redirect, 403/5xx typed errors, and network failures uniformly.",
},
],
},
},
]

export default config
2 changes: 2 additions & 0 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { NextIntlClientProvider } from 'next-intl'
import { getLocale, getMessages } from 'next-intl/server'
import { THEME_IDS } from '@/lib/themes'
import { ThemeBackground } from '@/components/ui/theme-background'
import { AuthExpiredListener } from '@/components/auth-expired-listener'
import './globals.css'

const inter = Inter({
Expand Down Expand Up @@ -114,6 +115,7 @@ export default async function RootLayout({
disableTransitionOnChange
>
<ThemeBackground />
<AuthExpiredListener />
<div className="h-screen overflow-hidden bg-background text-foreground">
{children}
</div>
Expand Down
33 changes: 33 additions & 0 deletions src/components/auth-expired-listener.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
'use client'

import { useEffect } from 'react'

/**
* Listens for `mc:auth-expired` CustomEvent dispatched by `apiFetch()` when the
* server returns 401. The redirect to `/login?from=...` is already handled inside
* `apiFetch`; this listener exists so we have a single observability hook the
* team can extend (toast, telemetry, Sentry).
*
* Mounted once at the root layout. SSR-safe (effect runs only on the client).
*
* Why a separate component?
* - layout.tsx is a server component (uses `await headers()`); we cannot
* attach window listeners there directly.
* - Co-locating the listener with the api-client keeps the auth-failure
* contract in one place.
*/
export function AuthExpiredListener(): null {
useEffect(() => {
const onExpired = (e: Event) => {
const detail = (e as CustomEvent<{ path: string; status: number }>).detail
// No toast lib installed yet — log for now. Replace with sonner in next PR.
console.warn(
`[mc] session expired on ${detail?.path ?? 'unknown'} (status=${detail?.status ?? 401}), redirecting to /login`
)
}
window.addEventListener('mc:auth-expired', onExpired)
return () => window.removeEventListener('mc:auth-expired', onExpired)
}, [])

return null
}
107 changes: 41 additions & 66 deletions src/components/panels/agent-squad-panel-phase3.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Loader } from '@/components/ui/loader'
import { useSmartPoll } from '@/lib/use-smart-poll'
import { createClientLogger } from '@/lib/client-logger'
import { AgentAvatar } from '@/components/ui/agent-avatar'
import { apiFetch } from '@/lib/api-client'
import {
OverviewTab,
SoulTab,
Expand Down Expand Up @@ -172,18 +173,16 @@ export function AgentSquadPanelPhase3() {
// Update agent status
const updateAgentStatus = async (agentName: string, status: Agent['status'], activity?: string) => {
try {
const response = await fetch('/api/agents', {
await apiFetch<{ ok?: boolean }>('/api/agents', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: agentName,
status,
last_activity: activity || `Status changed to ${status}`
})
}),
})

if (!response.ok) throw new Error('Failed to update agent status')

// Update store state
setAgents(agents.map(agent =>
agent.name === agentName
Expand All @@ -205,19 +204,14 @@ export function AgentSquadPanelPhase3() {
// Wake agent via session_send
const wakeAgent = async (agentName: string, sessionKey: string) => {
try {
const response = await fetch(`/api/agents/${agentName}/wake`, {
await apiFetch<{ error?: string }>(`/api/agents/${agentName}/wake`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message: `🤖 **Wake Up Call**\n\nAgent ${agentName}, you have been manually woken up.\nCheck Mission Control for any pending tasks or notifications.\n\n⏰ ${new Date().toLocaleString()}`
})
}),
})

if (!response.ok) {
const data = await response.json().catch(() => ({}))
throw new Error(data.error || 'Failed to wake agent')
}

await updateAgentStatus(agentName, 'idle', 'Manually woken via session')
} catch (error) {
log.error('Failed to wake agent:', error)
Expand All @@ -233,10 +227,9 @@ export function AgentSquadPanelPhase3() {

const toggleAgentHidden = async (agentId: number, hide: boolean) => {
try {
const response = await fetch(`/api/agents/${agentId}/hide`, {
await apiFetch<{ ok?: boolean }>(`/api/agents/${agentId}/hide`, {
method: hide ? 'POST' : 'DELETE',
})
if (!response.ok) throw new Error('Failed to update visibility')
fetchAgents()
} catch (error) {
log.error('Failed to toggle agent visibility:', error)
Expand All @@ -248,25 +241,24 @@ export function AgentSquadPanelPhase3() {
const previousAgents = agents
setAgents(agents.filter((agent) => agent.id !== agentId))

const response = await fetch(`/api/agents/${agentId}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ remove_workspace: removeWorkspace }),
})
try {
const payload = await apiFetch<{ deleted?: number; error?: string }>(`/api/agents/${agentId}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ remove_workspace: removeWorkspace }),
})

const payload = await response.json().catch(() => ({}))
if (!response.ok) {
setSyncToast(
removeWorkspace
? `Deleted agent and workspace: ${payload?.deleted || agentId}`
: `Deleted agent: ${payload?.deleted || agentId}`,
)
await fetchAgents()
setTimeout(() => setSyncToast(null), 5000)
} catch (err: any) {
setAgents(previousAgents)
throw new Error(payload?.error || 'Failed to delete agent')
throw err
}

setSyncToast(
removeWorkspace
? `Deleted agent and workspace: ${payload?.deleted || agentId}`
: `Deleted agent: ${payload?.deleted || agentId}`,
)
await fetchAgents()
setTimeout(() => setSyncToast(null), 5000)
}

// Format last seen time
Expand Down Expand Up @@ -645,10 +637,10 @@ function AgentDetailModalPhase3({
const loadCanonicalAgentData = async () => {
try {
const [agentRes, soulRes, memoryRes, filesRes] = await Promise.all([
fetch(`/api/agents/${agent.id}`),
fetch(`/api/agents/${agent.id}/soul`),
fetch(`/api/agents/${agent.id}/memory`),
fetch(`/api/agents/${agent.id}/files`),
apiFetch<Response>(`/api/agents/${agent.id}`, { raw: true }),
apiFetch<Response>(`/api/agents/${agent.id}/soul`, { raw: true }),
apiFetch<Response>(`/api/agents/${agent.id}/memory`, { raw: true }),
apiFetch<Response>(`/api/agents/${agent.id}/files`, { raw: true }),
])

if (agentRes.ok) {
Expand Down Expand Up @@ -705,13 +697,10 @@ function AgentDetailModalPhase3({
useEffect(() => {
const loadTemplates = async () => {
try {
const response = await fetch(`/api/agents/${agent.name}/soul`, {
const data = await apiFetch<{ templates?: any[] }>(`/api/agents/${agent.name}/soul`, {
method: 'PATCH'
})
if (response.ok) {
const data = await response.json()
setSoulTemplates(data.templates || [])
}
setSoulTemplates(data.templates || [])
} catch (error) {
log.error('Failed to load SOUL templates:', error)
}
Expand All @@ -726,11 +715,8 @@ function AgentDetailModalPhase3({
const performHeartbeat = async () => {
setLoadingHeartbeat(true)
try {
const response = await fetch(`/api/agents/${agent.name}/heartbeat`)
if (response.ok) {
const data = await response.json()
setHeartbeatData(data)
}
const data = await apiFetch<any>(`/api/agents/${agent.name}/heartbeat`)
setHeartbeatData(data)
} catch (error) {
log.error('Failed to perform heartbeat:', error)
} finally {
Expand All @@ -741,17 +727,15 @@ function AgentDetailModalPhase3({
const handleSave = async () => {
setSaveBusy(true)
try {
const response = await fetch('/api/agents', {
await apiFetch<{ ok?: boolean }>('/api/agents', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: agentState.name,
...formData
})
}),
})

if (!response.ok) throw new Error('Failed to update agent')

setEditing(false)
onUpdate()
} catch (error) {
Expand All @@ -763,16 +747,14 @@ function AgentDetailModalPhase3({

const handleSoulSave = async (content: string, templateName?: string) => {
try {
const response = await fetch(`/api/agents/${agentState.id}/soul`, {
await apiFetch<{ ok?: boolean }>(`/api/agents/${agentState.id}/soul`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
soul_content: content,
template_name: templateName
})
}),
})

if (!response.ok) throw new Error('Failed to update SOUL')

setFormData(prev => ({ ...prev, soul_content: content }))
setAgentState(prev => ({ ...prev, soul_content: content }))
Expand All @@ -784,36 +766,28 @@ function AgentDetailModalPhase3({

const handleMemorySave = async (content: string, append: boolean = false) => {
try {
const response = await fetch(`/api/agents/${agentState.id}/memory`, {
const data = await apiFetch<{ working_memory?: string }>(`/api/agents/${agentState.id}/memory`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
working_memory: content,
append
})
}),
})

if (!response.ok) throw new Error('Failed to update memory')

const data = await response.json()
setFormData(prev => ({ ...prev, working_memory: data.working_memory }))
setAgentState(prev => ({ ...prev, working_memory: data.working_memory }))
setFormData(prev => ({ ...prev, working_memory: data.working_memory ?? '' }))
setAgentState(prev => ({ ...prev, working_memory: data.working_memory ?? '' }))
onUpdate()
} catch (error) {
log.error('Failed to update memory:', error)
}
}

const handleWorkspaceFileSave = async (file: 'identity.md' | 'agent.md', content: string) => {
const response = await fetch(`/api/agents/${agentState.id}/files`, {
await apiFetch<{ error?: string }>(`/api/agents/${agentState.id}/files`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file, content }),
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
throw new Error(payload?.error || `Failed to save ${file}`)
}
setWorkspaceFiles((prev) => ({
...prev,
...(file === 'identity.md' ? { identityMd: content } : { agentMd: content }),
Expand Down Expand Up @@ -1084,14 +1058,15 @@ function QuickSpawnModal({

setIsSpawning(true)
try {
const response = await fetch('/api/spawn', {
const response = await apiFetch<Response>('/api/spawn', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...spawnData,
parentAgent: agent.name,
sessionKey: agent.session_key
})
}),
raw: true,
})

const result = await response.json()
Expand Down
Loading