antigravity-chat-proxy 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +362 -0
  2. package/app/api/v1/artifacts/[convId]/[filename]/route.ts +75 -0
  3. package/app/api/v1/artifacts/[convId]/route.ts +47 -0
  4. package/app/api/v1/artifacts/active/[filename]/route.ts +50 -0
  5. package/app/api/v1/artifacts/active/route.ts +89 -0
  6. package/app/api/v1/artifacts/route.ts +43 -0
  7. package/app/api/v1/chat/action/route.ts +30 -0
  8. package/app/api/v1/chat/approve/route.ts +21 -0
  9. package/app/api/v1/chat/history/route.ts +23 -0
  10. package/app/api/v1/chat/mode/route.ts +59 -0
  11. package/app/api/v1/chat/new/route.ts +21 -0
  12. package/app/api/v1/chat/reject/route.ts +21 -0
  13. package/app/api/v1/chat/route.ts +105 -0
  14. package/app/api/v1/chat/state/route.ts +23 -0
  15. package/app/api/v1/chat/stream/route.ts +258 -0
  16. package/app/api/v1/conversations/active/route.ts +117 -0
  17. package/app/api/v1/conversations/route.ts +189 -0
  18. package/app/api/v1/conversations/select/route.ts +114 -0
  19. package/app/api/v1/debug/dom/route.ts +30 -0
  20. package/app/api/v1/debug/scrape/route.ts +56 -0
  21. package/app/api/v1/health/route.ts +13 -0
  22. package/app/api/v1/windows/cdp-start/route.ts +32 -0
  23. package/app/api/v1/windows/cdp-status/route.ts +32 -0
  24. package/app/api/v1/windows/close/route.ts +67 -0
  25. package/app/api/v1/windows/open/route.ts +49 -0
  26. package/app/api/v1/windows/recent/route.ts +25 -0
  27. package/app/api/v1/windows/route.ts +27 -0
  28. package/app/api/v1/windows/select/route.ts +35 -0
  29. package/app/debug/page.tsx +228 -0
  30. package/app/favicon.ico +0 -0
  31. package/app/globals.css +1234 -0
  32. package/app/layout.tsx +42 -0
  33. package/app/page.tsx +10 -0
  34. package/bin/cli.js +601 -0
  35. package/components/agent-message.tsx +63 -0
  36. package/components/artifact-panel.tsx +133 -0
  37. package/components/chat-container.tsx +82 -0
  38. package/components/chat-input.tsx +92 -0
  39. package/components/conversation-selector.tsx +97 -0
  40. package/components/header.tsx +302 -0
  41. package/components/hitl-dialog.tsx +23 -0
  42. package/components/message-list.tsx +41 -0
  43. package/components/thinking-block.tsx +14 -0
  44. package/components/tool-call-card.tsx +75 -0
  45. package/components/typing-indicator.tsx +11 -0
  46. package/components/user-message.tsx +13 -0
  47. package/components/welcome-screen.tsx +38 -0
  48. package/hooks/use-artifacts.ts +85 -0
  49. package/hooks/use-chat.ts +278 -0
  50. package/hooks/use-conversations.ts +190 -0
  51. package/lib/actions/hitl.ts +113 -0
  52. package/lib/actions/new-chat.ts +116 -0
  53. package/lib/actions/send-message.ts +31 -0
  54. package/lib/actions/switch-conversation.ts +92 -0
  55. package/lib/cdp/connection.ts +95 -0
  56. package/lib/cdp/process-manager.ts +327 -0
  57. package/lib/cdp/recent-projects.ts +137 -0
  58. package/lib/cdp/selectors.ts +11 -0
  59. package/lib/context.ts +38 -0
  60. package/lib/init.ts +48 -0
  61. package/lib/logger.ts +32 -0
  62. package/lib/scraper/agent-mode.ts +122 -0
  63. package/lib/scraper/agent-state.ts +756 -0
  64. package/lib/scraper/chat-history.ts +138 -0
  65. package/lib/scraper/ide-conversations.ts +124 -0
  66. package/lib/sse/diff-states.ts +141 -0
  67. package/lib/types.ts +146 -0
  68. package/lib/utils.ts +7 -0
  69. package/next.config.ts +7 -0
  70. package/package.json +50 -0
  71. package/public/file.svg +1 -0
  72. package/public/globe.svg +1 -0
  73. package/public/next.svg +1 -0
  74. package/public/vercel.svg +1 -0
  75. package/public/window.svg +1 -0
  76. package/tsconfig.json +34 -0
@@ -0,0 +1,30 @@
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { ensureCdpConnection } from '@/lib/init';
3
+ import ctx from '@/lib/context';
4
+ import { clickActionButton } from '@/lib/actions/hitl';
5
+
6
+ export const dynamic = 'force-dynamic';
7
+
8
+ export async function POST(request: NextRequest) {
9
+ await ensureCdpConnection();
10
+ if (!ctx.workbenchPage) {
11
+ return NextResponse.json({ error: 'Not connected' }, { status: 503 });
12
+ }
13
+
14
+ const body = await request.json();
15
+ const { toolId, buttonText } = body;
16
+ if (!buttonText) {
17
+ return NextResponse.json(
18
+ { error: 'buttonText is required' },
19
+ { status: 400 }
20
+ );
21
+ }
22
+
23
+ try {
24
+ const result = await clickActionButton(ctx, toolId || null, buttonText);
25
+ ctx.lastActionTimestamp = Date.now();
26
+ return NextResponse.json(result, { status: result.success ? 200 : 404 });
27
+ } catch (e: any) {
28
+ return NextResponse.json({ error: e.message }, { status: 500 });
29
+ }
30
+ }
@@ -0,0 +1,21 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { ensureCdpConnection } from '@/lib/init';
3
+ import ctx from '@/lib/context';
4
+ import { clickApproveButton } from '@/lib/actions/hitl';
5
+
6
+ export const dynamic = 'force-dynamic';
7
+
8
+ export async function POST() {
9
+ await ensureCdpConnection();
10
+ if (!ctx.workbenchPage) {
11
+ return NextResponse.json({ error: 'Not connected' }, { status: 503 });
12
+ }
13
+
14
+ try {
15
+ const result = await clickApproveButton(ctx);
16
+ ctx.lastActionTimestamp = Date.now();
17
+ return NextResponse.json(result, { status: result.success ? 200 : 404 });
18
+ } catch (e: any) {
19
+ return NextResponse.json({ error: e.message }, { status: 500 });
20
+ }
21
+ }
@@ -0,0 +1,23 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { ensureCdpConnection } from '@/lib/init';
3
+ import ctx from '@/lib/context';
4
+ import { getChatHistory } from '@/lib/scraper/chat-history';
5
+
6
+ export const dynamic = 'force-dynamic';
7
+
8
+ export async function GET() {
9
+ await ensureCdpConnection();
10
+ if (!ctx.workbenchPage) {
11
+ return NextResponse.json(
12
+ { error: 'Not connected to Antigravity' },
13
+ { status: 503 }
14
+ );
15
+ }
16
+
17
+ try {
18
+ const history = await getChatHistory(ctx);
19
+ return NextResponse.json(history);
20
+ } catch (e: any) {
21
+ return NextResponse.json({ error: e.message }, { status: 500 });
22
+ }
23
+ }
@@ -0,0 +1,59 @@
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { ensureCdpConnection } from '@/lib/init';
3
+ import ctx from '@/lib/context';
4
+ import { getAgentMode, setAgentMode } from '@/lib/scraper/agent-mode';
5
+ import type { AgentMode } from '@/lib/scraper/agent-mode';
6
+
7
+ export const dynamic = 'force-dynamic';
8
+
9
+ /**
10
+ * GET /api/v1/chat/mode — Read the current conversation mode.
11
+ */
12
+ export async function GET() {
13
+ await ensureCdpConnection();
14
+ if (!ctx.workbenchPage) {
15
+ return NextResponse.json(
16
+ { error: 'Not connected to Antigravity' },
17
+ { status: 503 }
18
+ );
19
+ }
20
+
21
+ try {
22
+ const mode = await getAgentMode(ctx);
23
+ return NextResponse.json({ mode });
24
+ } catch (e: any) {
25
+ return NextResponse.json({ error: e.message }, { status: 500 });
26
+ }
27
+ }
28
+
29
+ /**
30
+ * POST /api/v1/chat/mode — Switch the conversation mode.
31
+ * Body: { mode: 'planning' | 'fast' }
32
+ */
33
+ export async function POST(req: NextRequest) {
34
+ await ensureCdpConnection();
35
+ if (!ctx.workbenchPage) {
36
+ return NextResponse.json(
37
+ { error: 'Not connected to Antigravity' },
38
+ { status: 503 }
39
+ );
40
+ }
41
+
42
+ try {
43
+ const body = await req.json();
44
+ const targetMode = body.mode as AgentMode;
45
+
46
+ if (targetMode !== 'planning' && targetMode !== 'fast') {
47
+ return NextResponse.json(
48
+ { error: 'Invalid mode. Must be "planning" or "fast".' },
49
+ { status: 400 }
50
+ );
51
+ }
52
+
53
+ await setAgentMode(ctx, targetMode);
54
+ const currentMode = await getAgentMode(ctx);
55
+ return NextResponse.json({ success: true, mode: currentMode });
56
+ } catch (e: any) {
57
+ return NextResponse.json({ error: e.message }, { status: 500 });
58
+ }
59
+ }
@@ -0,0 +1,21 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { ensureCdpConnection } from '@/lib/init';
3
+ import ctx from '@/lib/context';
4
+ import { startNewChat } from '@/lib/actions/new-chat';
5
+
6
+ export const dynamic = 'force-dynamic';
7
+
8
+ export async function POST() {
9
+ await ensureCdpConnection();
10
+ if (!ctx.workbenchPage) {
11
+ return NextResponse.json({ error: 'Not connected' }, { status: 503 });
12
+ }
13
+
14
+ try {
15
+ const result = await startNewChat(ctx);
16
+ ctx.lastActionTimestamp = Date.now();
17
+ return NextResponse.json(result, { status: result.success ? 200 : 404 });
18
+ } catch (e: any) {
19
+ return NextResponse.json({ error: e.message }, { status: 500 });
20
+ }
21
+ }
@@ -0,0 +1,21 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { ensureCdpConnection } from '@/lib/init';
3
+ import ctx from '@/lib/context';
4
+ import { clickRejectButton } from '@/lib/actions/hitl';
5
+
6
+ export const dynamic = 'force-dynamic';
7
+
8
+ export async function POST() {
9
+ await ensureCdpConnection();
10
+ if (!ctx.workbenchPage) {
11
+ return NextResponse.json({ error: 'Not connected' }, { status: 503 });
12
+ }
13
+
14
+ try {
15
+ const result = await clickRejectButton(ctx);
16
+ ctx.lastActionTimestamp = Date.now();
17
+ return NextResponse.json(result, { status: result.success ? 200 : 404 });
18
+ } catch (e: any) {
19
+ return NextResponse.json({ error: e.message }, { status: 500 });
20
+ }
21
+ }
@@ -0,0 +1,105 @@
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { ensureCdpConnection } from '@/lib/init';
3
+ import ctx from '@/lib/context';
4
+ import { sendMessage } from '@/lib/actions/send-message';
5
+ import { getFullAgentState } from '@/lib/scraper/agent-state';
6
+ import { sleep } from '@/lib/utils';
7
+
8
+ export const dynamic = 'force-dynamic';
9
+
10
+ /**
11
+ * POST /api/v1/chat — Blocking chat endpoint.
12
+ * Sends a message and polls until the agent completes, then returns the response.
13
+ */
14
+ export async function POST(request: NextRequest) {
15
+ await ensureCdpConnection();
16
+ if (!ctx.workbenchPage) {
17
+ return NextResponse.json(
18
+ { error: 'Not connected to Antigravity' },
19
+ { status: 503 }
20
+ );
21
+ }
22
+
23
+ const body = await request.json();
24
+ const { message } = body;
25
+ if (!message) {
26
+ return NextResponse.json(
27
+ { error: 'message is required' },
28
+ { status: 400 }
29
+ );
30
+ }
31
+
32
+ try {
33
+ await sendMessage(ctx, message);
34
+
35
+ const startTime = Date.now();
36
+ const timeoutMs = 180000;
37
+ let doneCount = 0;
38
+ let started = false;
39
+ const initialState = await getFullAgentState(ctx);
40
+ const initialBlockCount = initialState.responses.length + initialState.notifications.length;
41
+
42
+ // Phase 1: Wait for agent to start
43
+ for (let i = 0; i < 40; i++) {
44
+ await sleep(300);
45
+ const state = await getFullAgentState(ctx);
46
+ if (state.isRunning) {
47
+ started = true;
48
+ break;
49
+ }
50
+ const blocks = state.responses.length + state.notifications.length;
51
+ if (blocks > initialBlockCount) {
52
+ started = true;
53
+ await sleep(500);
54
+ const check = await getFullAgentState(ctx);
55
+ if (!check.isRunning) {
56
+ const response = check.notifications.length > 0
57
+ ? check.notifications[check.notifications.length - 1]
58
+ : check.responses.length > 0
59
+ ? check.responses[check.responses.length - 1]
60
+ : '';
61
+ return NextResponse.json({ response });
62
+ }
63
+ break;
64
+ }
65
+ }
66
+
67
+ if (!started) {
68
+ const state = await getFullAgentState(ctx);
69
+ const response = state.responses.length > 0
70
+ ? state.responses[state.responses.length - 1]
71
+ : '[Agent did not respond]';
72
+ return NextResponse.json({ response });
73
+ }
74
+
75
+ // Phase 2: Wait for completion
76
+ while (Date.now() - startTime < timeoutMs) {
77
+ const state = await getFullAgentState(ctx);
78
+ if (state.error) {
79
+ return NextResponse.json({ response: state.error });
80
+ }
81
+ if (!state.isRunning) {
82
+ doneCount++;
83
+ if (doneCount >= 3) {
84
+ const response = state.notifications.length > 0
85
+ ? state.notifications[state.notifications.length - 1]
86
+ : state.responses.length > 0
87
+ ? state.responses[state.responses.length - 1]
88
+ : '[Agent did not produce a response]';
89
+ return NextResponse.json({ response });
90
+ }
91
+ } else {
92
+ doneCount = 0;
93
+ }
94
+ await sleep(500);
95
+ }
96
+
97
+ const finalState = await getFullAgentState(ctx);
98
+ const response = finalState.responses.length > 0
99
+ ? finalState.responses[finalState.responses.length - 1]
100
+ : '[Timeout: No response received]';
101
+ return NextResponse.json({ response });
102
+ } catch (e: any) {
103
+ return NextResponse.json({ error: e.message }, { status: 500 });
104
+ }
105
+ }
@@ -0,0 +1,23 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { ensureCdpConnection } from '@/lib/init';
3
+ import ctx from '@/lib/context';
4
+ import { getFullAgentState } from '@/lib/scraper/agent-state';
5
+
6
+ export const dynamic = 'force-dynamic';
7
+
8
+ export async function GET() {
9
+ await ensureCdpConnection();
10
+ if (!ctx.workbenchPage) {
11
+ return NextResponse.json(
12
+ { error: 'Not connected to Antigravity' },
13
+ { status: 503 }
14
+ );
15
+ }
16
+
17
+ try {
18
+ const state = await getFullAgentState(ctx);
19
+ return NextResponse.json(state);
20
+ } catch (e: any) {
21
+ return NextResponse.json({ error: e.message }, { status: 500 });
22
+ }
23
+ }
@@ -0,0 +1,258 @@
1
+ import { NextRequest } from 'next/server';
2
+ import { ensureCdpConnection } from '@/lib/init';
3
+ import ctx from '@/lib/context';
4
+ import { sendMessage } from '@/lib/actions/send-message';
5
+ import { getFullAgentState } from '@/lib/scraper/agent-state';
6
+ import { diffStates } from '@/lib/sse/diff-states';
7
+ import type { AgentState, ToolCall } from '@/lib/types';
8
+
9
+ export const dynamic = 'force-dynamic';
10
+
11
+ /**
12
+ * POST /api/v1/chat/stream — SSE streaming endpoint.
13
+ * Sends a message and streams agent state diffs as Server-Sent Events.
14
+ */
15
+ export async function POST(request: NextRequest) {
16
+ await ensureCdpConnection();
17
+ if (!ctx.workbenchPage) {
18
+ return new Response(
19
+ JSON.stringify({ error: 'Not connected to Antigravity' }),
20
+ { status: 503, headers: { 'Content-Type': 'application/json' } }
21
+ );
22
+ }
23
+
24
+ const body = await request.json();
25
+ const { message } = body;
26
+ if (!message) {
27
+ return new Response(
28
+ JSON.stringify({ error: 'message is required' }),
29
+ { status: 400, headers: { 'Content-Type': 'application/json' } }
30
+ );
31
+ }
32
+
33
+ const encoder = new TextEncoder();
34
+
35
+ const stream = new ReadableStream({
36
+ async start(controller) {
37
+ const writeEvent = (type: string, data: Record<string, unknown>) => {
38
+ try {
39
+ const payload = JSON.stringify({ ...data, type });
40
+ controller.enqueue(encoder.encode(`data: ${payload}\n\n`));
41
+ } catch {
42
+ // Controller may already be closed
43
+ }
44
+ };
45
+
46
+ let closed = false;
47
+ const closeStream = () => {
48
+ if (closed) return;
49
+ closed = true;
50
+ try { controller.close(); } catch { /* already closed */ }
51
+ };
52
+
53
+ try {
54
+ writeEvent('status', { isRunning: true, phase: 'sending' });
55
+
56
+ // Capture initial state before sending
57
+ let prevState = await getFullAgentState(ctx);
58
+ const sessionToolCalls = new Map<string, ToolCall>();
59
+ let sessionResponses: string[] = [];
60
+
61
+ await sendMessage(ctx, message);
62
+
63
+ writeEvent('status', { isRunning: true, phase: 'waiting' });
64
+
65
+ const startTime = Date.now();
66
+ let doneCount = 0;
67
+ let started = false;
68
+ let lastStableHTML = '';
69
+ const initialTurnCount = prevState.turnCount;
70
+ let pollErrorCount = 0;
71
+
72
+ const interval = setInterval(async () => {
73
+ if (closed) { clearInterval(interval); return; }
74
+
75
+ try {
76
+ const currState = await getFullAgentState(ctx);
77
+ pollErrorCount = 0; // Reset on success
78
+
79
+ // Track tools by ID to survive virtualization
80
+ if (currState.turnCount > prevState.turnCount) {
81
+ sessionToolCalls.clear();
82
+ prevState = {
83
+ ...prevState,
84
+ toolCalls: [],
85
+ responses: [],
86
+ thinking: [],
87
+ notifications: [],
88
+ fileChanges: [],
89
+ };
90
+ }
91
+ for (const t of currState.toolCalls) {
92
+ sessionToolCalls.set(t.id, t);
93
+ }
94
+ currState.toolCalls = Array.from(sessionToolCalls.values());
95
+
96
+ // Accumulate responses to survive DOM virtualization
97
+ if (currState.responses.length > sessionResponses.length) {
98
+ sessionResponses = [...currState.responses];
99
+ } else if (
100
+ currState.responses.length < sessionResponses.length &&
101
+ currState.responses.length > 0
102
+ ) {
103
+ const lastIdx = currState.responses.length - 1;
104
+ sessionResponses[sessionResponses.length - 1] =
105
+ currState.responses[lastIdx];
106
+ } else if (
107
+ currState.responses.length === sessionResponses.length &&
108
+ currState.responses.length > 0
109
+ ) {
110
+ sessionResponses[sessionResponses.length - 1] =
111
+ currState.responses[currState.responses.length - 1];
112
+ }
113
+ currState.responses = [...sessionResponses];
114
+
115
+ // Detect start
116
+ if (!started) {
117
+ if (
118
+ currState.isRunning ||
119
+ currState.turnCount > initialTurnCount ||
120
+ currState.toolCalls.length > prevState.toolCalls.length ||
121
+ currState.responses.length > prevState.responses.length ||
122
+ currState.thinking.length > prevState.thinking.length
123
+ ) {
124
+ started = true;
125
+ writeEvent('status', { isRunning: true, phase: 'processing' });
126
+ }
127
+ }
128
+
129
+ // Check for unresolved tools
130
+ const hasUnresolvedTools = Array.from(
131
+ sessionToolCalls.values()
132
+ ).some((t) => t.hasCancelBtn && !t.exitCode);
133
+
134
+ // Compute and emit diffs
135
+ const events = diffStates(prevState, currState);
136
+ for (const evt of events) {
137
+ writeEvent(evt.type, evt.data);
138
+ }
139
+
140
+ // Check for completion
141
+ if (started && !currState.isRunning && !currState.error && !hasUnresolvedTools) {
142
+ const contentChanged =
143
+ currState.toolCalls.length !== prevState.toolCalls.length ||
144
+ currState.responses.length !== prevState.responses.length ||
145
+ currState.thinking.length !== prevState.thinking.length ||
146
+ currState.notifications.length !== prevState.notifications.length ||
147
+ currState.fileChanges.length !== prevState.fileChanges.length ||
148
+ currState.stepGroupCount !== prevState.stepGroupCount ||
149
+ (currState.responses.length > 0 &&
150
+ prevState.responses.length > 0 &&
151
+ currState.responses[currState.responses.length - 1] !==
152
+ prevState.responses[prevState.responses.length - 1]) ||
153
+ currState.lastTurnResponseHTML !== prevState.lastTurnResponseHTML;
154
+
155
+ if (contentChanged) {
156
+ doneCount = 0;
157
+ lastStableHTML = '';
158
+ } else if (Date.now() - ctx.lastActionTimestamp < 15000) {
159
+ doneCount = 0;
160
+ lastStableHTML = '';
161
+ } else {
162
+ doneCount++;
163
+ }
164
+
165
+ const currentHTML = currState.lastTurnResponseHTML || '';
166
+ if (doneCount >= 2 && currentHTML && currentHTML !== lastStableHTML) {
167
+ doneCount = 1;
168
+ }
169
+ lastStableHTML = currentHTML;
170
+
171
+ const hasSubagentTools = currState.toolCalls.some(
172
+ (t) =>
173
+ t.type === 'browser' ||
174
+ (t.status || '').toLowerCase().includes('subagent') ||
175
+ (t.status || '').toLowerCase().includes('navigat')
176
+ );
177
+ const requiredDoneCount = hasSubagentTools ? 20 : 10;
178
+ if (doneCount >= requiredDoneCount) {
179
+ const finalResponse =
180
+ currState.notifications.length > 0
181
+ ? currState.notifications[currState.notifications.length - 1]
182
+ : currState.responses.length > 0
183
+ ? currState.responses[currState.responses.length - 1]
184
+ : '';
185
+
186
+ writeEvent('done', {
187
+ finalResponse,
188
+ isHTML: true,
189
+ thinking: currState.thinking,
190
+ toolCalls: currState.toolCalls,
191
+ });
192
+ clearInterval(interval);
193
+ closeStream();
194
+ return;
195
+ }
196
+ } else {
197
+ doneCount = 0;
198
+ lastStableHTML = '';
199
+ }
200
+
201
+ // Error
202
+ if (currState.error) {
203
+ writeEvent('error', { message: currState.error });
204
+ writeEvent('done', { error: currState.error });
205
+ clearInterval(interval);
206
+ closeStream();
207
+ return;
208
+ }
209
+
210
+ // Timeout (10 min)
211
+ if (Date.now() - startTime > 600000) {
212
+ const finalResponse =
213
+ currState.responses.length > 0
214
+ ? currState.responses[currState.responses.length - 1]
215
+ : '[Timeout]';
216
+ writeEvent('done', { finalResponse, timeout: true });
217
+ clearInterval(interval);
218
+ closeStream();
219
+ return;
220
+ }
221
+
222
+ prevState = currState;
223
+ } catch (e: any) {
224
+ // Transient errors: don't kill the stream, just log and retry
225
+ pollErrorCount++;
226
+ console.error(`[Stream] Poll error (${pollErrorCount}):`, e.message);
227
+
228
+ // Only kill after many consecutive failures
229
+ if (pollErrorCount >= 20) {
230
+ writeEvent('error', { message: `Too many poll errors: ${e.message}` });
231
+ writeEvent('done', { error: e.message });
232
+ clearInterval(interval);
233
+ closeStream();
234
+ }
235
+ }
236
+ }, 500);
237
+
238
+ // Handle client disconnect
239
+ request.signal.addEventListener('abort', () => {
240
+ clearInterval(interval);
241
+ closeStream();
242
+ });
243
+ } catch (e: any) {
244
+ writeEvent('error', { message: e.message });
245
+ closeStream();
246
+ }
247
+ },
248
+ });
249
+
250
+ return new Response(stream, {
251
+ headers: {
252
+ 'Content-Type': 'text/event-stream',
253
+ 'Cache-Control': 'no-cache',
254
+ Connection: 'keep-alive',
255
+ },
256
+ });
257
+ }
258
+
@@ -0,0 +1,117 @@
1
+ import { NextResponse } from 'next/server';
2
+ import ctx from '@/lib/context';
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+ import os from 'os';
6
+
7
+ export const dynamic = 'force-dynamic';
8
+
9
+ const BRAIN_DIR = path.join(os.homedir(), '.gemini', 'antigravity', 'brain');
10
+
11
+ function extractTitle(convDir: string): string | null {
12
+ const taskFile = path.join(convDir, 'task.md');
13
+ try {
14
+ if (fs.existsSync(taskFile)) {
15
+ const content = fs.readFileSync(taskFile, 'utf-8');
16
+ const lines = content.split('\n');
17
+ for (const line of lines) {
18
+ const trimmed = line.trim();
19
+ if (trimmed.startsWith('# ')) {
20
+ return trimmed.slice(2).trim();
21
+ }
22
+ }
23
+ }
24
+ } catch {
25
+ /* ignore */
26
+ }
27
+ return null;
28
+ }
29
+
30
+ function getConversationFiles(convDir: string) {
31
+ try {
32
+ const results: any[] = [];
33
+ const entries = fs.readdirSync(convDir, { recursive: true, withFileTypes: true });
34
+ for (const d of entries) {
35
+ if (!d.isFile() || !d.name.endsWith('.md')) continue;
36
+
37
+ // Node 20+ uses parentPath, older Node might use path. Fallback to convDir just in case
38
+ // @ts-ignore
39
+ const parentDir = d.parentPath || d.path || convDir;
40
+ const fullPath = path.join(parentDir, d.name);
41
+ const relPath = path.relative(convDir, fullPath).replace(/\\/g, '/');
42
+
43
+ if (relPath.split('/').some(p => p.startsWith('.'))) continue;
44
+
45
+ const stat = fs.statSync(fullPath);
46
+ results.push({ name: relPath, size: stat.size, mtime: stat.mtime.toISOString() });
47
+ }
48
+ return results;
49
+ } catch {
50
+ return [];
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Auto-detect the most recently modified conversation directory.
56
+ * This ensures the currently opened conversation is pre-selected on first load.
57
+ */
58
+ function autoDetectActiveConversation(): string | null {
59
+ try {
60
+ if (!fs.existsSync(BRAIN_DIR)) return null;
61
+ const entries = fs.readdirSync(BRAIN_DIR, { withFileTypes: true });
62
+ let latest: { id: string; mtime: number } | null = null;
63
+
64
+ for (const entry of entries) {
65
+ if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
66
+ const dirPath = path.join(BRAIN_DIR, entry.name);
67
+ const stat = fs.statSync(dirPath);
68
+ // Use the most recently modified directory
69
+ if (!latest || stat.mtimeMs > latest.mtime) {
70
+ latest = { id: entry.name, mtime: stat.mtimeMs };
71
+ }
72
+ }
73
+ return latest?.id ?? null;
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ export async function GET() {
80
+ // Auto-detect on first load if no conversation has been selected yet
81
+ if (!ctx.activeConversationId && !ctx.activeTitle) {
82
+ ctx.activeConversationId = autoDetectActiveConversation();
83
+ }
84
+
85
+ if (!ctx.activeConversationId && ctx.activeTitle) {
86
+ return NextResponse.json({
87
+ active: true,
88
+ id: null,
89
+ title: ctx.activeTitle,
90
+ files: [],
91
+ mtime: new Date().toISOString(),
92
+ });
93
+ }
94
+
95
+ if (!ctx.activeConversationId) {
96
+ return NextResponse.json({ active: false });
97
+ }
98
+
99
+ const convDir = path.join(BRAIN_DIR, ctx.activeConversationId);
100
+ if (!fs.existsSync(convDir)) {
101
+ ctx.activeConversationId = null;
102
+ return NextResponse.json({ active: false });
103
+ }
104
+
105
+ const files = getConversationFiles(convDir);
106
+ const title = extractTitle(convDir);
107
+ const stat = fs.statSync(convDir);
108
+
109
+ return NextResponse.json({
110
+ active: true,
111
+ id: ctx.activeConversationId,
112
+ title,
113
+ files,
114
+ mtime: stat.mtime.toISOString(),
115
+ });
116
+ }
117
+