Nyk commited on
Commit
f762929
·
1 Parent(s): 347f571

feat: add direct CLI integration for gateway-free tool connections

Browse files

- Add migration 016 for direct_connections table
- Add POST/GET/DELETE /api/connect for CLI tool registration
- Enhance heartbeat POST to accept connection_id and inline token_usage
- Add connectSchema to validation
- Add connection.created/disconnected event types to event bus
- Show direct CLI connections in gateway manager panel
- Add 5 E2E tests for connection lifecycle
- Add CLI integration documentation (docs/cli-integration.md)
- Fix openapi.json brace mismatch on line 642 (Phase 2 bug)
- Add /api/connect endpoints to OpenAPI spec

docs/cli-integration.md ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Direct CLI Integration
2
+
3
+ Connect CLI tools (Claude Code, Codex, custom agents) directly to Mission Control without a gateway.
4
+
5
+ ## Quick Start
6
+
7
+ ### 1. Register a connection
8
+
9
+ ```bash
10
+ curl -X POST http://localhost:3000/api/connect \
11
+ -H "Content-Type: application/json" \
12
+ -H "x-api-key: YOUR_API_KEY" \
13
+ -d '{
14
+ "tool_name": "claude-code",
15
+ "tool_version": "1.0.0",
16
+ "agent_name": "my-agent",
17
+ "agent_role": "developer"
18
+ }'
19
+ ```
20
+
21
+ Response:
22
+
23
+ ```json
24
+ {
25
+ "connection_id": "550e8400-e29b-41d4-a716-446655440000",
26
+ "agent_id": 42,
27
+ "agent_name": "my-agent",
28
+ "status": "connected",
29
+ "sse_url": "/api/events",
30
+ "heartbeat_url": "/api/agents/42/heartbeat",
31
+ "token_report_url": "/api/tokens"
32
+ }
33
+ ```
34
+
35
+ - If `agent_name` doesn't exist, it's auto-created.
36
+ - Previous connections for the same agent are automatically deactivated.
37
+
38
+ ### 2. Heartbeat loop
39
+
40
+ Send heartbeats to stay alive and optionally report token usage:
41
+
42
+ ```bash
43
+ curl -X POST http://localhost:3000/api/agents/42/heartbeat \
44
+ -H "Content-Type: application/json" \
45
+ -H "x-api-key: YOUR_API_KEY" \
46
+ -d '{
47
+ "connection_id": "550e8400-e29b-41d4-a716-446655440000",
48
+ "token_usage": {
49
+ "model": "claude-sonnet-4",
50
+ "inputTokens": 1500,
51
+ "outputTokens": 800
52
+ }
53
+ }'
54
+ ```
55
+
56
+ Response includes work items (mentions, assigned tasks, notifications) plus `"token_recorded": true` if usage was reported.
57
+
58
+ Recommended heartbeat interval: **30 seconds**.
59
+
60
+ ### 3. Subscribe to events (SSE)
61
+
62
+ ```bash
63
+ curl -N http://localhost:3000/api/events \
64
+ -H "x-api-key: YOUR_API_KEY"
65
+ ```
66
+
67
+ Receives real-time events: task assignments, mentions, agent status changes, etc.
68
+
69
+ ### 4. Report token usage
70
+
71
+ For bulk token reporting (separate from heartbeat):
72
+
73
+ ```bash
74
+ curl -X POST http://localhost:3000/api/tokens \
75
+ -H "Content-Type: application/json" \
76
+ -H "x-api-key: YOUR_API_KEY" \
77
+ -d '{
78
+ "model": "claude-sonnet-4",
79
+ "sessionId": "my-agent:chat",
80
+ "inputTokens": 5000,
81
+ "outputTokens": 2000
82
+ }'
83
+ ```
84
+
85
+ ### 5. Disconnect
86
+
87
+ ```bash
88
+ curl -X DELETE http://localhost:3000/api/connect \
89
+ -H "Content-Type: application/json" \
90
+ -H "x-api-key: YOUR_API_KEY" \
91
+ -d '{"connection_id": "550e8400-e29b-41d4-a716-446655440000"}'
92
+ ```
93
+
94
+ Sets the agent offline if no other active connections exist.
95
+
96
+ ## API Reference
97
+
98
+ | Method | Endpoint | Auth | Description |
99
+ |--------|----------|------|-------------|
100
+ | POST | `/api/connect` | operator | Register CLI connection |
101
+ | GET | `/api/connect` | viewer | List all connections |
102
+ | DELETE | `/api/connect` | operator | Disconnect by connection_id |
103
+ | POST | `/api/agents/{id}/heartbeat` | operator | Heartbeat with optional token reporting |
104
+ | GET | `/api/events` | viewer | SSE event stream |
105
+ | POST | `/api/tokens` | operator | Report token usage |
106
+
107
+ ## Connection Lifecycle
108
+
109
+ ```
110
+ POST /api/connect → Agent set online
111
+
112
+ Heartbeat loop (30s) → Reports tokens, receives work items
113
+
114
+ DELETE /api/connect → Agent set offline (if no other connections)
115
+ ```
116
+
117
+ ## Notes
118
+
119
+ - Each agent can only have one active connection at a time. A new `POST /api/connect` for the same agent deactivates the previous connection.
120
+ - The `sessionId` format for token reporting follows `{agentName}:{chatType}` convention (e.g., `my-agent:chat`, `my-agent:cli`).
121
+ - Heartbeat responses include pending work items (assigned tasks, mentions, notifications) so CLI tools can act on them.
openapi.json CHANGED
The diff for this file is too large to render. See raw diff
 
src/app/api/agents/[id]/heartbeat/route.ts CHANGED
@@ -167,8 +167,13 @@ export async function GET(
167
  }
168
 
169
  /**
170
- * POST /api/agents/[id]/heartbeat - Manual heartbeat trigger
171
- * Allows manual heartbeat checks from UI or scripts
 
 
 
 
 
172
  */
173
  export async function POST(
174
  request: NextRequest,
@@ -177,6 +182,51 @@ export async function POST(
177
  const auth = requireRole(request, 'operator');
178
  if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status });
179
 
180
- // Reuse GET logic for manual triggers
181
- return GET(request, { params });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
182
  }
 
167
  }
168
 
169
  /**
170
+ * POST /api/agents/[id]/heartbeat - Enhanced heartbeat
171
+ *
172
+ * Accepts optional body:
173
+ * - connection_id: update direct_connections.last_heartbeat
174
+ * - status: agent status override
175
+ * - last_activity: activity description
176
+ * - token_usage: { model, inputTokens, outputTokens } for inline token reporting
177
  */
178
  export async function POST(
179
  request: NextRequest,
 
182
  const auth = requireRole(request, 'operator');
183
  if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status });
184
 
185
+ let body: any = {};
186
+ try {
187
+ body = await request.json();
188
+ } catch {
189
+ // No body is fine — fall through to standard heartbeat
190
+ }
191
+
192
+ const { connection_id, token_usage } = body;
193
+ const db = getDatabase();
194
+ const now = Math.floor(Date.now() / 1000);
195
+
196
+ // Update direct connection heartbeat if connection_id provided
197
+ if (connection_id) {
198
+ db.prepare('UPDATE direct_connections SET last_heartbeat = ?, updated_at = ? WHERE connection_id = ? AND status = ?')
199
+ .run(now, now, connection_id, 'connected');
200
+ }
201
+
202
+ // Inline token reporting
203
+ let tokenRecorded = false;
204
+ if (token_usage && token_usage.model && token_usage.inputTokens != null && token_usage.outputTokens != null) {
205
+ const resolvedParams = await params;
206
+ const agentId = resolvedParams.id;
207
+ let agent: any;
208
+ if (isNaN(Number(agentId))) {
209
+ agent = db.prepare('SELECT * FROM agents WHERE name = ?').get(agentId);
210
+ } else {
211
+ agent = db.prepare('SELECT * FROM agents WHERE id = ?').get(Number(agentId));
212
+ }
213
+
214
+ if (agent) {
215
+ const sessionId = `${agent.name}:cli`;
216
+ db.prepare(
217
+ `INSERT INTO token_usage (model, session_id, input_tokens, output_tokens, created_at)
218
+ VALUES (?, ?, ?, ?, ?)`
219
+ ).run(token_usage.model, sessionId, token_usage.inputTokens, token_usage.outputTokens, now);
220
+ tokenRecorded = true;
221
+ }
222
+ }
223
+
224
+ // Reuse GET logic for work-items check, then augment response
225
+ const getResponse = await GET(request, { params });
226
+ const getBody = await getResponse.json();
227
+
228
+ return NextResponse.json({
229
+ ...getBody,
230
+ token_recorded: tokenRecorded,
231
+ });
232
  }
src/app/api/connect/route.ts ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from 'next/server'
2
+ import { getDatabase, db_helpers } from '@/lib/db'
3
+ import { requireRole } from '@/lib/auth'
4
+ import { validateBody, connectSchema } from '@/lib/validation'
5
+ import { eventBus } from '@/lib/event-bus'
6
+ import { randomUUID } from 'crypto'
7
+
8
+ /**
9
+ * POST /api/connect — Register a direct CLI connection
10
+ *
11
+ * Auto-creates agent if name doesn't exist, deactivates previous connections
12
+ * for the same agent, and returns connection details + helper URLs.
13
+ */
14
+ export async function POST(request: NextRequest) {
15
+ const auth = requireRole(request, 'operator')
16
+ if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
17
+
18
+ const validation = await validateBody(request, connectSchema)
19
+ if ('error' in validation) return validation.error
20
+
21
+ const { tool_name, tool_version, agent_name, agent_role, metadata } = validation.data
22
+ const db = getDatabase()
23
+ const now = Math.floor(Date.now() / 1000)
24
+
25
+ // Find or create agent
26
+ let agent = db.prepare('SELECT * FROM agents WHERE name = ?').get(agent_name) as any
27
+ if (!agent) {
28
+ const result = db.prepare(
29
+ `INSERT INTO agents (name, role, status, created_at, updated_at)
30
+ VALUES (?, ?, 'online', ?, ?)`
31
+ ).run(agent_name, agent_role || 'cli', now, now)
32
+ agent = { id: result.lastInsertRowid, name: agent_name }
33
+ db_helpers.logActivity('agent_created', 'agent', agent.id as number, 'system',
34
+ `Auto-created agent "${agent_name}" via direct CLI connection`)
35
+ eventBus.broadcast('agent.created', { id: agent.id, name: agent_name })
36
+ } else {
37
+ // Set agent online
38
+ db.prepare('UPDATE agents SET status = ?, updated_at = ? WHERE id = ?')
39
+ .run('online', now, agent.id)
40
+ eventBus.broadcast('agent.status_changed', { id: agent.id, name: agent.name, status: 'online' })
41
+ }
42
+
43
+ // Deactivate previous connections for this agent
44
+ db.prepare(
45
+ `UPDATE direct_connections SET status = 'disconnected', updated_at = ? WHERE agent_id = ? AND status = 'connected'`
46
+ ).run(now, agent.id)
47
+
48
+ // Create new connection
49
+ const connectionId = randomUUID()
50
+ db.prepare(
51
+ `INSERT INTO direct_connections (agent_id, tool_name, tool_version, connection_id, status, last_heartbeat, metadata, created_at, updated_at)
52
+ VALUES (?, ?, ?, ?, 'connected', ?, ?, ?, ?)`
53
+ ).run(agent.id, tool_name, tool_version || null, connectionId, now, metadata ? JSON.stringify(metadata) : null, now, now)
54
+
55
+ db_helpers.logActivity('connection_created', 'agent', agent.id as number, agent_name,
56
+ `CLI connection established via ${tool_name}${tool_version ? ` v${tool_version}` : ''}`)
57
+
58
+ eventBus.broadcast('connection.created', {
59
+ connection_id: connectionId,
60
+ agent_id: agent.id,
61
+ agent_name,
62
+ tool_name,
63
+ })
64
+
65
+ return NextResponse.json({
66
+ connection_id: connectionId,
67
+ agent_id: agent.id,
68
+ agent_name,
69
+ status: 'connected',
70
+ sse_url: `/api/events`,
71
+ heartbeat_url: `/api/agents/${agent.id}/heartbeat`,
72
+ token_report_url: `/api/tokens`,
73
+ })
74
+ }
75
+
76
+ /**
77
+ * GET /api/connect — List all direct connections
78
+ */
79
+ export async function GET(request: NextRequest) {
80
+ const auth = requireRole(request, 'viewer')
81
+ if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
82
+
83
+ const db = getDatabase()
84
+ const connections = db.prepare(`
85
+ SELECT dc.*, a.name as agent_name, a.status as agent_status, a.role as agent_role
86
+ FROM direct_connections dc
87
+ JOIN agents a ON dc.agent_id = a.id
88
+ ORDER BY dc.created_at DESC
89
+ `).all()
90
+
91
+ return NextResponse.json({ connections })
92
+ }
93
+
94
+ /**
95
+ * DELETE /api/connect — Disconnect by connection_id
96
+ */
97
+ export async function DELETE(request: NextRequest) {
98
+ const auth = requireRole(request, 'operator')
99
+ if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
100
+
101
+ let body: any
102
+ try {
103
+ body = await request.json()
104
+ } catch {
105
+ return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
106
+ }
107
+
108
+ const { connection_id } = body
109
+ if (!connection_id) {
110
+ return NextResponse.json({ error: 'connection_id is required' }, { status: 400 })
111
+ }
112
+
113
+ const db = getDatabase()
114
+ const now = Math.floor(Date.now() / 1000)
115
+
116
+ const conn = db.prepare('SELECT * FROM direct_connections WHERE connection_id = ?').get(connection_id) as any
117
+ if (!conn) {
118
+ return NextResponse.json({ error: 'Connection not found' }, { status: 404 })
119
+ }
120
+
121
+ db.prepare('UPDATE direct_connections SET status = ?, updated_at = ? WHERE connection_id = ?')
122
+ .run('disconnected', now, connection_id)
123
+
124
+ // Check if agent has other active connections; if not, set offline
125
+ const otherActive = db.prepare(
126
+ 'SELECT COUNT(*) as count FROM direct_connections WHERE agent_id = ? AND status = ? AND connection_id != ?'
127
+ ).get(conn.agent_id, 'connected', connection_id) as any
128
+ if (!otherActive?.count) {
129
+ db.prepare('UPDATE agents SET status = ?, updated_at = ? WHERE id = ?')
130
+ .run('offline', now, conn.agent_id)
131
+ }
132
+
133
+ const agent = db.prepare('SELECT name FROM agents WHERE id = ?').get(conn.agent_id) as any
134
+ db_helpers.logActivity('connection_disconnected', 'agent', conn.agent_id, agent?.name || 'unknown',
135
+ `CLI connection disconnected (${conn.tool_name})`)
136
+
137
+ eventBus.broadcast('connection.disconnected', {
138
+ connection_id,
139
+ agent_id: conn.agent_id,
140
+ agent_name: agent?.name,
141
+ })
142
+
143
+ return NextResponse.json({ status: 'disconnected', connection_id })
144
+ }
src/components/panels/multi-gateway-panel.tsx CHANGED
@@ -20,8 +20,24 @@ interface Gateway {
20
  updated_at: number
21
  }
22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  export function MultiGatewayPanel() {
24
  const [gateways, setGateways] = useState<Gateway[]>([])
 
25
  const [loading, setLoading] = useState(true)
26
  const [showAdd, setShowAdd] = useState(false)
27
  const [probing, setProbing] = useState<number | null>(null)
@@ -37,7 +53,15 @@ export function MultiGatewayPanel() {
37
  setLoading(false)
38
  }, [])
39
 
40
- useEffect(() => { fetchGateways() }, [fetchGateways])
 
 
 
 
 
 
 
 
41
 
42
  const setPrimary = async (gw: Gateway) => {
43
  await fetch('/api/gateways', {
@@ -75,6 +99,17 @@ export function MultiGatewayPanel() {
75
  setProbing(null)
76
  }
77
 
 
 
 
 
 
 
 
 
 
 
 
78
  return (
79
  <div className="p-4 md:p-6 max-w-5xl mx-auto space-y-6">
80
  {/* Header */}
@@ -146,6 +181,70 @@ export function MultiGatewayPanel() {
146
  ))}
147
  </div>
148
  )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
149
  </div>
150
  )
151
  }
 
20
  updated_at: number
21
  }
22
 
23
+ interface DirectConnection {
24
+ id: number
25
+ agent_id: number
26
+ tool_name: string
27
+ tool_version: string | null
28
+ connection_id: string
29
+ status: string
30
+ last_heartbeat: number | null
31
+ metadata: string | null
32
+ created_at: number
33
+ agent_name: string
34
+ agent_status: string
35
+ agent_role: string
36
+ }
37
+
38
  export function MultiGatewayPanel() {
39
  const [gateways, setGateways] = useState<Gateway[]>([])
40
+ const [directConnections, setDirectConnections] = useState<DirectConnection[]>([])
41
  const [loading, setLoading] = useState(true)
42
  const [showAdd, setShowAdd] = useState(false)
43
  const [probing, setProbing] = useState<number | null>(null)
 
53
  setLoading(false)
54
  }, [])
55
 
56
+ const fetchDirectConnections = useCallback(async () => {
57
+ try {
58
+ const res = await fetch('/api/connect')
59
+ const data = await res.json()
60
+ setDirectConnections(data.connections || [])
61
+ } catch { /* ignore */ }
62
+ }, [])
63
+
64
+ useEffect(() => { fetchGateways(); fetchDirectConnections() }, [fetchGateways, fetchDirectConnections])
65
 
66
  const setPrimary = async (gw: Gateway) => {
67
  await fetch('/api/gateways', {
 
99
  setProbing(null)
100
  }
101
 
102
+ const disconnectCli = async (connectionId: string) => {
103
+ try {
104
+ await fetch('/api/connect', {
105
+ method: 'DELETE',
106
+ headers: { 'Content-Type': 'application/json' },
107
+ body: JSON.stringify({ connection_id: connectionId }),
108
+ })
109
+ fetchDirectConnections()
110
+ } catch { /* ignore */ }
111
+ }
112
+
113
  return (
114
  <div className="p-4 md:p-6 max-w-5xl mx-auto space-y-6">
115
  {/* Header */}
 
181
  ))}
182
  </div>
183
  )}
184
+
185
+ {/* Direct CLI Connections */}
186
+ <div>
187
+ <div className="flex items-center justify-between mb-3">
188
+ <div>
189
+ <h3 className="text-sm font-semibold text-foreground">Direct CLI Connections</h3>
190
+ <p className="text-xs text-muted-foreground mt-0.5">
191
+ CLI tools connected directly without a gateway
192
+ </p>
193
+ </div>
194
+ <button
195
+ onClick={fetchDirectConnections}
196
+ className="h-7 px-2.5 rounded-md text-2xs font-medium bg-secondary text-foreground hover:bg-secondary/80 transition-smooth"
197
+ >
198
+ Refresh
199
+ </button>
200
+ </div>
201
+ {directConnections.length === 0 ? (
202
+ <div className="text-center py-8 bg-card border border-border rounded-lg">
203
+ <p className="text-xs text-muted-foreground">No direct CLI connections</p>
204
+ <p className="text-2xs text-muted-foreground mt-1">
205
+ Use <code className="font-mono bg-secondary px-1 rounded">POST /api/connect</code> to register a CLI tool
206
+ </p>
207
+ </div>
208
+ ) : (
209
+ <div className="space-y-2">
210
+ {directConnections.map(conn => (
211
+ <div key={conn.id} className="bg-card border border-border rounded-lg p-4">
212
+ <div className="flex items-start justify-between gap-3">
213
+ <div className="flex-1 min-w-0">
214
+ <div className="flex items-center gap-2">
215
+ <span className={`w-2 h-2 rounded-full ${conn.status === 'connected' ? 'bg-green-500' : 'bg-red-500'}`} />
216
+ <span className="text-sm font-semibold text-foreground">{conn.agent_name}</span>
217
+ <span className="text-2xs px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-400 border border-blue-500/30 font-medium">
218
+ {conn.tool_name}{conn.tool_version ? ` v${conn.tool_version}` : ''}
219
+ </span>
220
+ <span className={`text-2xs px-1.5 py-0.5 rounded font-medium ${
221
+ conn.status === 'connected'
222
+ ? 'bg-green-500/20 text-green-400 border border-green-500/30'
223
+ : 'bg-red-500/20 text-red-400 border border-red-500/30'
224
+ }`}>
225
+ {conn.status.toUpperCase()}
226
+ </span>
227
+ </div>
228
+ <div className="flex items-center gap-4 mt-1.5 text-xs text-muted-foreground">
229
+ <span>Role: {conn.agent_role || 'cli'}</span>
230
+ <span>Heartbeat: {conn.last_heartbeat ? new Date(conn.last_heartbeat * 1000).toLocaleString() : 'Never'}</span>
231
+ <span className="font-mono text-2xs">{conn.connection_id.slice(0, 8)}...</span>
232
+ </div>
233
+ </div>
234
+ {conn.status === 'connected' && (
235
+ <button
236
+ onClick={() => disconnectCli(conn.connection_id)}
237
+ className="h-7 px-2.5 rounded-md text-2xs font-medium text-red-400 hover:bg-red-500/10 transition-smooth"
238
+ >
239
+ Disconnect
240
+ </button>
241
+ )}
242
+ </div>
243
+ </div>
244
+ ))}
245
+ </div>
246
+ )}
247
+ </div>
248
  </div>
249
  )
250
  }
src/lib/event-bus.ts CHANGED
@@ -28,6 +28,8 @@ export type EventType =
28
  | 'agent.synced'
29
  | 'agent.status_changed'
30
  | 'audit.security'
 
 
31
 
32
  class ServerEventBus extends EventEmitter {
33
  private static instance: ServerEventBus | null = null
 
28
  | 'agent.synced'
29
  | 'agent.status_changed'
30
  | 'audit.security'
31
+ | 'connection.created'
32
+ | 'connection.disconnected'
33
 
34
  class ServerEventBus extends EventEmitter {
35
  private static instance: ServerEventBus | null = null
src/lib/migrations.ts CHANGED
@@ -436,6 +436,28 @@ const migrations: Migration[] = [
436
  CREATE INDEX IF NOT EXISTS idx_messages_read_at ON messages(read_at);
437
  `)
438
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
439
  }
440
  ]
441
 
 
436
  CREATE INDEX IF NOT EXISTS idx_messages_read_at ON messages(read_at);
437
  `)
438
  }
439
+ },
440
+ {
441
+ id: '016_direct_connections',
442
+ up: (db) => {
443
+ db.exec(`
444
+ CREATE TABLE IF NOT EXISTS direct_connections (
445
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
446
+ agent_id INTEGER NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
447
+ tool_name TEXT NOT NULL,
448
+ tool_version TEXT,
449
+ connection_id TEXT NOT NULL UNIQUE,
450
+ status TEXT NOT NULL DEFAULT 'connected',
451
+ last_heartbeat INTEGER,
452
+ metadata TEXT,
453
+ created_at INTEGER NOT NULL DEFAULT (unixepoch()),
454
+ updated_at INTEGER NOT NULL DEFAULT (unixepoch())
455
+ );
456
+ CREATE INDEX IF NOT EXISTS idx_direct_connections_agent_id ON direct_connections(agent_id);
457
+ CREATE INDEX IF NOT EXISTS idx_direct_connections_connection_id ON direct_connections(connection_id);
458
+ CREATE INDEX IF NOT EXISTS idx_direct_connections_status ON direct_connections(status);
459
+ `)
460
+ }
461
  }
462
  ]
463
 
src/lib/validation.ts CHANGED
@@ -153,3 +153,11 @@ export const accessRequestActionSchema = z.object({
153
  role: z.enum(['admin', 'operator', 'viewer']).default('viewer'),
154
  note: z.string().optional(),
155
  })
 
 
 
 
 
 
 
 
 
153
  role: z.enum(['admin', 'operator', 'viewer']).default('viewer'),
154
  note: z.string().optional(),
155
  })
156
+
157
+ export const connectSchema = z.object({
158
+ tool_name: z.string().min(1, 'Tool name is required').max(100),
159
+ tool_version: z.string().max(50).optional(),
160
+ agent_name: z.string().min(1, 'Agent name is required').max(100),
161
+ agent_role: z.string().max(100).optional(),
162
+ metadata: z.record(z.string(), z.unknown()).optional(),
163
+ })
tests/direct-cli.spec.ts ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { test, expect } from '@playwright/test'
2
+ import { API_KEY_HEADER } from './helpers'
3
+
4
+ test.describe('Direct CLI Integration', () => {
5
+ const createdConnectionIds: string[] = []
6
+ const createdAgentIds: number[] = []
7
+
8
+ test.afterEach(async ({ request }) => {
9
+ // Clean up connections
10
+ for (const connId of createdConnectionIds) {
11
+ await request.delete('/api/connect', {
12
+ headers: API_KEY_HEADER,
13
+ data: { connection_id: connId },
14
+ })
15
+ }
16
+ createdConnectionIds.length = 0
17
+
18
+ // Clean up auto-created agents
19
+ for (const agentId of createdAgentIds) {
20
+ await request.delete(`/api/agents/${agentId}`, { headers: API_KEY_HEADER })
21
+ }
22
+ createdAgentIds.length = 0
23
+ })
24
+
25
+ test('POST /api/connect creates connection and auto-creates agent', async ({ request }) => {
26
+ const agentName = `e2e-cli-${Date.now()}`
27
+ const res = await request.post('/api/connect', {
28
+ headers: API_KEY_HEADER,
29
+ data: {
30
+ tool_name: 'claude-code',
31
+ tool_version: '1.0.0',
32
+ agent_name: agentName,
33
+ agent_role: 'developer',
34
+ },
35
+ })
36
+ expect(res.status()).toBe(200)
37
+ const body = await res.json()
38
+
39
+ expect(body.connection_id).toBeDefined()
40
+ expect(body.agent_id).toBeDefined()
41
+ expect(body.agent_name).toBe(agentName)
42
+ expect(body.status).toBe('connected')
43
+ expect(body.sse_url).toBe('/api/events')
44
+ expect(body.heartbeat_url).toContain('/api/agents/')
45
+ expect(body.token_report_url).toBe('/api/tokens')
46
+
47
+ createdConnectionIds.push(body.connection_id)
48
+ createdAgentIds.push(body.agent_id)
49
+
50
+ // Verify agent was created
51
+ const agentRes = await request.get(`/api/agents/${body.agent_id}`, {
52
+ headers: API_KEY_HEADER,
53
+ })
54
+ expect(agentRes.status()).toBe(200)
55
+ const agentBody = await agentRes.json()
56
+ expect(agentBody.agent.name).toBe(agentName)
57
+ expect(agentBody.agent.status).toBe('online')
58
+ })
59
+
60
+ test('GET /api/connect lists connections', async ({ request }) => {
61
+ const agentName = `e2e-cli-list-${Date.now()}`
62
+ const postRes = await request.post('/api/connect', {
63
+ headers: API_KEY_HEADER,
64
+ data: {
65
+ tool_name: 'codex',
66
+ agent_name: agentName,
67
+ },
68
+ })
69
+ const postBody = await postRes.json()
70
+ createdConnectionIds.push(postBody.connection_id)
71
+ createdAgentIds.push(postBody.agent_id)
72
+
73
+ const res = await request.get('/api/connect', { headers: API_KEY_HEADER })
74
+ expect(res.status()).toBe(200)
75
+ const body = await res.json()
76
+ expect(Array.isArray(body.connections)).toBe(true)
77
+ const found = body.connections.find((c: any) => c.connection_id === postBody.connection_id)
78
+ expect(found).toBeDefined()
79
+ expect(found.agent_name).toBe(agentName)
80
+ expect(found.tool_name).toBe('codex')
81
+ })
82
+
83
+ test('POST heartbeat with inline token_usage', async ({ request }) => {
84
+ const agentName = `e2e-cli-hb-${Date.now()}`
85
+ const postRes = await request.post('/api/connect', {
86
+ headers: API_KEY_HEADER,
87
+ data: {
88
+ tool_name: 'claude-code',
89
+ agent_name: agentName,
90
+ },
91
+ })
92
+ const postBody = await postRes.json()
93
+ createdConnectionIds.push(postBody.connection_id)
94
+ createdAgentIds.push(postBody.agent_id)
95
+
96
+ const hbRes = await request.post(`/api/agents/${postBody.agent_id}/heartbeat`, {
97
+ headers: API_KEY_HEADER,
98
+ data: {
99
+ connection_id: postBody.connection_id,
100
+ token_usage: {
101
+ model: 'claude-sonnet-4',
102
+ inputTokens: 1000,
103
+ outputTokens: 500,
104
+ },
105
+ },
106
+ })
107
+ expect(hbRes.status()).toBe(200)
108
+ const hbBody = await hbRes.json()
109
+ expect(hbBody.token_recorded).toBe(true)
110
+ expect(hbBody.agent).toBe(agentName)
111
+ })
112
+
113
+ test('DELETE /api/connect disconnects and sets agent offline', async ({ request }) => {
114
+ const agentName = `e2e-cli-del-${Date.now()}`
115
+ const postRes = await request.post('/api/connect', {
116
+ headers: API_KEY_HEADER,
117
+ data: {
118
+ tool_name: 'claude-code',
119
+ agent_name: agentName,
120
+ },
121
+ })
122
+ const postBody = await postRes.json()
123
+ createdAgentIds.push(postBody.agent_id)
124
+
125
+ const delRes = await request.delete('/api/connect', {
126
+ headers: API_KEY_HEADER,
127
+ data: { connection_id: postBody.connection_id },
128
+ })
129
+ expect(delRes.status()).toBe(200)
130
+ const delBody = await delRes.json()
131
+ expect(delBody.status).toBe('disconnected')
132
+
133
+ // Agent should be offline
134
+ const agentRes = await request.get(`/api/agents/${postBody.agent_id}`, {
135
+ headers: API_KEY_HEADER,
136
+ })
137
+ const agentBody = await agentRes.json()
138
+ expect(agentBody.agent.status).toBe('offline')
139
+ })
140
+
141
+ test('POST /api/connect requires auth', async ({ request }) => {
142
+ const res = await request.post('/api/connect', {
143
+ data: {
144
+ tool_name: 'claude-code',
145
+ agent_name: 'unauthorized-agent',
146
+ },
147
+ })
148
+ expect(res.status()).toBe(401)
149
+ })
150
+ })