agentgui 1.0.382 → 1.0.383

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 (3) hide show
  1. package/acp-queries.js +22 -2
  2. package/package.json +1 -1
  3. package/server.js +603 -40
package/acp-queries.js CHANGED
@@ -144,8 +144,28 @@ export function createACPQueries(db, prep) {
144
144
  const ths = rows.map(r => ({ thread_id: r.id, created_at: iso(r.created_at), updated_at: iso(r.updated_at), metadata: jp(r.metadata), status: r.status || 'idle' }));
145
145
  return { threads: ths, total: tot, limit, offset, hasMore: offset + limit < tot };
146
146
  },
147
- searchAgents(flt = {}) {
148
- return [];
147
+ searchAgents(agents, flt = {}) {
148
+ const { name, version, capabilities, limit = 50, offset = 0 } = flt;
149
+ let results = agents;
150
+ if (name) {
151
+ const n = name.toLowerCase();
152
+ results = results.filter(a => a.name.toLowerCase().includes(n) || a.id.toLowerCase().includes(n));
153
+ }
154
+ if (capabilities) {
155
+ results = results.filter(a => {
156
+ const desc = this.getAgentDescriptor ? this.getAgentDescriptor(a.id) : null;
157
+ if (!desc) return false;
158
+ const caps = desc.specs?.capabilities || {};
159
+ if (capabilities.streaming !== undefined && !caps.streaming) return false;
160
+ if (capabilities.threads !== undefined && caps.threads !== capabilities.threads) return false;
161
+ if (capabilities.interrupts !== undefined && caps.interrupts !== capabilities.interrupts) return false;
162
+ return true;
163
+ });
164
+ }
165
+ const total = results.length;
166
+ const paginated = results.slice(offset, offset + limit);
167
+ const agents_list = paginated.map(a => ({ agent_id: a.id, name: a.name, version: version || '1.0.0', path: a.path }));
168
+ return { agents: agents_list, total, limit, offset, hasMore: offset + limit < total };
149
169
  },
150
170
  searchRuns(flt = {}) {
151
171
  const { agent_id, thread_id, status, limit = 50, offset = 0 } = flt;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentgui",
3
- "version": "1.0.382",
3
+ "version": "1.0.383",
4
4
  "description": "Multi-agent ACP client with real-time communication",
5
5
  "type": "module",
6
6
  "main": "server.js",
package/server.js CHANGED
@@ -1444,9 +1444,9 @@ const server = http.createServer(async (req, res) => {
1444
1444
  return;
1445
1445
  }
1446
1446
 
1447
- const runByIdMatch = pathOnly.match(/^\/api\/runs\/([^/]+)$/);
1448
- if (runByIdMatch) {
1449
- const runId = runByIdMatch[1];
1447
+ const oldRunByIdMatch = pathOnly.match(/^\/api\/runs\/([^/]+)$/);
1448
+ if (oldRunByIdMatch) {
1449
+ const runId = oldRunByIdMatch[1];
1450
1450
  const session = queries.getSession(runId);
1451
1451
 
1452
1452
  if (!session) {
@@ -1511,9 +1511,9 @@ const server = http.createServer(async (req, res) => {
1511
1511
  }
1512
1512
  }
1513
1513
 
1514
- const runCancelMatch = pathOnly.match(/^\/api\/runs\/([^/]+)\/cancel$/);
1514
+ const oldRunCancelMatch = pathOnly.match(/^\/api\/runs\/([^/]+)\/cancel$/);
1515
1515
  if (runCancelMatch && req.method === 'POST') {
1516
- const runId = runCancelMatch[1];
1516
+ const runId = oldRunCancelMatch[1];
1517
1517
  const session = queries.getSession(runId);
1518
1518
 
1519
1519
  if (!session) {
@@ -1767,34 +1767,10 @@ const server = http.createServer(async (req, res) => {
1767
1767
  return;
1768
1768
  }
1769
1769
 
1770
- const agentsSearchMatch = pathOnly.match(/^\/api\/agents\/search$/);
1771
- if (agentsSearchMatch && req.method === 'POST') {
1772
- let body = '';
1773
- for await (const chunk of req) { body += chunk; }
1774
- let parsed = {};
1775
- try { parsed = body ? JSON.parse(body) : {}; } catch {}
1776
-
1777
- const { query } = parsed;
1778
- let results = discoveredAgents;
1779
-
1780
- if (query) {
1781
- const q = query.toLowerCase();
1782
- results = discoveredAgents.filter(a =>
1783
- a.name.toLowerCase().includes(q) ||
1784
- a.id.toLowerCase().includes(q) ||
1785
- (a.description && a.description.toLowerCase().includes(q))
1786
- );
1787
- }
1788
-
1789
- const agents = results.map(a => ({
1790
- id: a.id,
1791
- name: a.name,
1792
- description: a.description || '',
1793
- icon: a.icon || null,
1794
- status: 'available'
1795
- }));
1796
-
1797
- sendJSON(req, res, 200, agents);
1770
+ if (pathOnly === '/api/agents/search' && req.method === 'POST') {
1771
+ const body = await parseBody(req);
1772
+ const result = acpQueries.searchAgents(discoveredAgents, body);
1773
+ sendJSON(req, res, 200, result);
1798
1774
  return;
1799
1775
  }
1800
1776
 
@@ -1915,6 +1891,214 @@ const server = http.createServer(async (req, res) => {
1915
1891
  return;
1916
1892
  }
1917
1893
 
1894
+ if (pathOnly === '/api/runs' && req.method === 'POST') {
1895
+ const body = await parseBody(req);
1896
+ const { agent_id, input, config, webhook_url } = body;
1897
+ if (!agent_id) {
1898
+ sendJSON(req, res, 422, { error: 'agent_id is required' });
1899
+ return;
1900
+ }
1901
+ const agent = discoveredAgents.find(a => a.id === agent_id);
1902
+ if (!agent) {
1903
+ sendJSON(req, res, 404, { error: 'Agent not found' });
1904
+ return;
1905
+ }
1906
+ const run = acpQueries.createRun(agent_id, null, input, config, webhook_url);
1907
+ sendJSON(req, res, 201, run);
1908
+ return;
1909
+ }
1910
+
1911
+ if (pathOnly === '/api/runs/search' && req.method === 'POST') {
1912
+ const body = await parseBody(req);
1913
+ const result = acpQueries.searchRuns(body);
1914
+ sendJSON(req, res, 200, result);
1915
+ return;
1916
+ }
1917
+
1918
+ if (pathOnly === '/api/runs/stream' && req.method === 'POST') {
1919
+ const body = await parseBody(req);
1920
+ const { agent_id, input, config } = body;
1921
+ if (!agent_id) {
1922
+ sendJSON(req, res, 422, { error: 'agent_id is required' });
1923
+ return;
1924
+ }
1925
+ const agent = discoveredAgents.find(a => a.id === agent_id);
1926
+ if (!agent) {
1927
+ sendJSON(req, res, 404, { error: 'Agent not found' });
1928
+ return;
1929
+ }
1930
+ const run = acpQueries.createRun(agent_id, null, input, config);
1931
+ res.writeHead(200, {
1932
+ 'Content-Type': 'text/event-stream',
1933
+ 'Cache-Control': 'no-cache',
1934
+ 'Connection': 'keep-alive'
1935
+ });
1936
+ res.write('data: ' + JSON.stringify({ type: 'run_created', run_id: run.run_id }) + '\n\n');
1937
+ const eventHandler = (eventData) => {
1938
+ if (eventData.sessionId === run.run_id || eventData.conversationId === run.thread_id) {
1939
+ res.write('data: ' + JSON.stringify(eventData) + '\n\n');
1940
+ }
1941
+ };
1942
+ const cleanup = () => {
1943
+ res.end();
1944
+ };
1945
+ req.on('close', cleanup);
1946
+ const statelessThreadId = acpQueries.getRun(run.run_id)?.thread_id;
1947
+ if (statelessThreadId) {
1948
+ const conv = queries.getConversation(statelessThreadId);
1949
+ if (conv && input?.content) {
1950
+ runClaudeWithStreaming(agent_id, statelessThreadId, input.content, config?.model || null).catch(() => {});
1951
+ }
1952
+ }
1953
+ return;
1954
+ }
1955
+
1956
+ if (pathOnly === '/api/runs/wait' && req.method === 'POST') {
1957
+ const body = await parseBody(req);
1958
+ const { agent_id, input, config } = body;
1959
+ if (!agent_id) {
1960
+ sendJSON(req, res, 422, { error: 'agent_id is required' });
1961
+ return;
1962
+ }
1963
+ const agent = discoveredAgents.find(a => a.id === agent_id);
1964
+ if (!agent) {
1965
+ sendJSON(req, res, 404, { error: 'Agent not found' });
1966
+ return;
1967
+ }
1968
+ const run = acpQueries.createRun(agent_id, null, input, config);
1969
+ const statelessThreadId = acpQueries.getRun(run.run_id)?.thread_id;
1970
+ if (statelessThreadId && input?.content) {
1971
+ try {
1972
+ await runClaudeWithStreaming(agent_id, statelessThreadId, input.content, config?.model || null);
1973
+ const finalRun = acpQueries.getRun(run.run_id);
1974
+ sendJSON(req, res, 200, finalRun);
1975
+ } catch (err) {
1976
+ acpQueries.updateRunStatus(run.run_id, 'error');
1977
+ sendJSON(req, res, 500, { error: err.message });
1978
+ }
1979
+ } else {
1980
+ sendJSON(req, res, 200, run);
1981
+ }
1982
+ return;
1983
+ }
1984
+
1985
+ const oldRunByIdMatch1 = pathOnly.match(/^\/api\/runs\/([^/]+)$/);
1986
+ if (oldRunByIdMatch1) {
1987
+ const runId = oldRunByIdMatch1[1];
1988
+
1989
+ if (req.method === 'GET') {
1990
+ const run = acpQueries.getRun(runId);
1991
+ if (!run) {
1992
+ sendJSON(req, res, 404, { error: 'Run not found' });
1993
+ return;
1994
+ }
1995
+ sendJSON(req, res, 200, run);
1996
+ return;
1997
+ }
1998
+
1999
+ if (req.method === 'POST') {
2000
+ const body = await parseBody(req);
2001
+ const run = acpQueries.getRun(runId);
2002
+ if (!run) {
2003
+ sendJSON(req, res, 404, { error: 'Run not found' });
2004
+ return;
2005
+ }
2006
+ if (run.status !== 'pending') {
2007
+ sendJSON(req, res, 409, { error: 'Run is not resumable' });
2008
+ return;
2009
+ }
2010
+ if (body.input?.content && run.thread_id) {
2011
+ runClaudeWithStreaming(run.agent_id, run.thread_id, body.input.content, null).catch(() => {});
2012
+ }
2013
+ sendJSON(req, res, 200, run);
2014
+ return;
2015
+ }
2016
+
2017
+ if (req.method === 'DELETE') {
2018
+ try {
2019
+ acpQueries.deleteRun(runId);
2020
+ res.writeHead(204);
2021
+ res.end();
2022
+ } catch (err) {
2023
+ sendJSON(req, res, 404, { error: 'Run not found' });
2024
+ }
2025
+ return;
2026
+ }
2027
+ }
2028
+
2029
+ const runWaitMatch = pathOnly.match(/^\/api\/runs\/([^/]+)\/wait$/);
2030
+ if (runWaitMatch && req.method === 'GET') {
2031
+ const runId = runWaitMatch[1];
2032
+ const run = acpQueries.getRun(runId);
2033
+ if (!run) {
2034
+ sendJSON(req, res, 404, { error: 'Run not found' });
2035
+ return;
2036
+ }
2037
+ const startTime = Date.now();
2038
+ const pollInterval = setInterval(() => {
2039
+ const currentRun = acpQueries.getRun(runId);
2040
+ if (!currentRun || ['success', 'error', 'cancelled'].includes(currentRun.status) || (Date.now() - startTime) > 30000) {
2041
+ clearInterval(pollInterval);
2042
+ sendJSON(req, res, 200, currentRun || run);
2043
+ }
2044
+ }, 500);
2045
+ req.on('close', () => clearInterval(pollInterval));
2046
+ return;
2047
+ }
2048
+
2049
+ const runStreamMatch = pathOnly.match(/^\/api\/runs\/([^/]+)\/stream$/);
2050
+ if (runStreamMatch && req.method === 'GET') {
2051
+ const runId = runStreamMatch[1];
2052
+ const run = acpQueries.getRun(runId);
2053
+ if (!run) {
2054
+ sendJSON(req, res, 404, { error: 'Run not found' });
2055
+ return;
2056
+ }
2057
+ res.writeHead(200, {
2058
+ 'Content-Type': 'text/event-stream',
2059
+ 'Cache-Control': 'no-cache',
2060
+ 'Connection': 'keep-alive'
2061
+ });
2062
+ res.write('data: ' + JSON.stringify({ type: 'joined', run_id: runId }) + '\n\n');
2063
+ const eventHandler = (eventData) => {
2064
+ if (eventData.sessionId === runId || eventData.conversationId === run.thread_id) {
2065
+ res.write('data: ' + JSON.stringify(eventData) + '\n\n');
2066
+ }
2067
+ };
2068
+ const cleanup = () => {
2069
+ res.end();
2070
+ };
2071
+ req.on('close', cleanup);
2072
+ return;
2073
+ }
2074
+
2075
+ const oldRunCancelMatch1 = pathOnly.match(/^\/api\/runs\/([^/]+)\/cancel$/);
2076
+ if (runCancelMatch && req.method === 'POST') {
2077
+ const runId = oldRunCancelMatch1[1];
2078
+ try {
2079
+ const run = acpQueries.cancelRun(runId);
2080
+ const execution = activeExecutions.get(run.thread_id);
2081
+ if (execution?.process) {
2082
+ execution.process.kill('SIGTERM');
2083
+ setTimeout(() => {
2084
+ if (execution.process && !execution.process.killed) {
2085
+ execution.process.kill('SIGKILL');
2086
+ }
2087
+ }, 5000);
2088
+ }
2089
+ sendJSON(req, res, 200, run);
2090
+ } catch (err) {
2091
+ if (err.message === 'Run not found') {
2092
+ sendJSON(req, res, 404, { error: err.message });
2093
+ } else if (err.message.includes('already completed')) {
2094
+ sendJSON(req, res, 409, { error: err.message });
2095
+ } else {
2096
+ sendJSON(req, res, 500, { error: err.message });
2097
+ }
2098
+ }
2099
+ return;
2100
+ }
2101
+
1918
2102
  if (pathOnly === '/api/gemini-oauth/start' && req.method === 'POST') {
1919
2103
  try {
1920
2104
  const result = await startGeminiOAuth(req);
@@ -2174,9 +2358,9 @@ const server = http.createServer(async (req, res) => {
2174
2358
  return;
2175
2359
  }
2176
2360
 
2177
- const threadByIdMatch = pathOnly.match(/^\/api\/threads\/([^/]+)$/);
2178
- if (threadByIdMatch) {
2179
- const threadId = threadByIdMatch[1];
2361
+ const oldThreadByIdMatch = pathOnly.match(/^\/api\/threads\/([^/]+)$/);
2362
+ if (oldThreadByIdMatch) {
2363
+ const threadId = oldThreadByIdMatch[1];
2180
2364
  const conv = queries.getConversation(threadId);
2181
2365
 
2182
2366
  if (!conv) {
@@ -2236,9 +2420,9 @@ const server = http.createServer(async (req, res) => {
2236
2420
  }
2237
2421
  }
2238
2422
 
2239
- const threadHistoryMatch = pathOnly.match(/^\/api\/threads\/([^/]+)\/history$/);
2423
+ const oldThreadHistoryMatch = pathOnly.match(/^\/api\/threads\/([^/]+)\/history$/);
2240
2424
  if (threadHistoryMatch && req.method === 'GET') {
2241
- const threadId = threadHistoryMatch[1];
2425
+ const threadId = oldThreadHistoryMatch[1];
2242
2426
  const conv = queries.getConversation(threadId);
2243
2427
 
2244
2428
  if (!conv) {
@@ -2261,7 +2445,7 @@ const server = http.createServer(async (req, res) => {
2261
2445
  return;
2262
2446
  }
2263
2447
 
2264
- const threadCopyMatch = pathOnly.match(/^\/api\/threads\/([^/]+)\/copy$/);
2448
+ const oldThreadCopyMatch = pathOnly.match(/^\/api\/threads\/([^/]+)\/copy$/);
2265
2449
  if (threadCopyMatch && req.method === 'POST') {
2266
2450
  const threadId = threadCopyMatch[1];
2267
2451
  const original = queries.getConversation(threadId);
@@ -2357,6 +2541,143 @@ const server = http.createServer(async (req, res) => {
2357
2541
  }
2358
2542
  }
2359
2543
 
2544
+ const threadRunsStreamMatch = pathOnly.match(/^\/api\/threads\/([^\/]+)\/runs\/stream$/);
2545
+ if (threadRunsStreamMatch && req.method === 'POST') {
2546
+ const threadId = threadRunsStreamMatch[1];
2547
+ const conv = queries.getConversation(threadId);
2548
+
2549
+ if (!conv) {
2550
+ sendJSON(req, res, 404, { error: 'Thread not found' });
2551
+ return;
2552
+ }
2553
+
2554
+ const activeEntry = activeExecutions.get(threadId);
2555
+ if (activeEntry) {
2556
+ sendJSON(req, res, 409, { error: 'Thread already has an active run' });
2557
+ return;
2558
+ }
2559
+
2560
+ let body = '';
2561
+ for await (const chunk of req) { body += chunk; }
2562
+ let parsed = {};
2563
+ try { parsed = body ? JSON.parse(body) : {}; } catch {}
2564
+
2565
+ const { input, agentId } = parsed;
2566
+ if (!input) {
2567
+ sendJSON(req, res, 400, { error: 'Missing input in request body' });
2568
+ return;
2569
+ }
2570
+
2571
+ const resolvedAgentId = agentId || conv.agentId || 'claude-code';
2572
+ const resolvedModel = parsed.model || conv.model || null;
2573
+
2574
+ const session = queries.createSession(threadId, resolvedAgentId, 'pending');
2575
+ const message = queries.createMessage(threadId, 'user', typeof input === 'string' ? input : JSON.stringify(input));
2576
+
2577
+ res.writeHead(200, {
2578
+ 'Content-Type': 'text/event-stream',
2579
+ 'Cache-Control': 'no-cache',
2580
+ 'Connection': 'keep-alive'
2581
+ });
2582
+
2583
+ const eventListeners = [];
2584
+ const broadcastListener = (event) => {
2585
+ if (event.sessionId === session.id) {
2586
+ if (event.type === 'streaming_progress') {
2587
+ res.write(`event: message\ndata: ${JSON.stringify(event)}\n\n`);
2588
+ } else if (event.type === 'streaming_complete') {
2589
+ res.write(`event: done\ndata: ${JSON.stringify({ status: 'completed' })}\n\n`);
2590
+ res.end();
2591
+ } else if (event.type === 'streaming_error') {
2592
+ res.write(`event: error\ndata: ${JSON.stringify({ error: event.error })}\n\n`);
2593
+ res.end();
2594
+ }
2595
+ }
2596
+ };
2597
+ eventListeners.push(broadcastListener);
2598
+
2599
+ req.on('close', () => {
2600
+ eventListeners.length = 0;
2601
+ });
2602
+
2603
+ processMessageWithStreaming(threadId, message.id, session.id, typeof input === 'string' ? input : JSON.stringify(input), resolvedAgentId, resolvedModel);
2604
+ return;
2605
+ }
2606
+
2607
+ const threadRunsWaitMatch = pathOnly.match(/^\/api\/threads\/([^\/]+)\/runs\/wait$/);
2608
+ if (threadRunsWaitMatch && req.method === 'POST') {
2609
+ const threadId = threadRunsWaitMatch[1];
2610
+ const conv = queries.getConversation(threadId);
2611
+
2612
+ if (!conv) {
2613
+ sendJSON(req, res, 404, { error: 'Thread not found' });
2614
+ return;
2615
+ }
2616
+
2617
+ const activeEntry = activeExecutions.get(threadId);
2618
+ if (activeEntry) {
2619
+ sendJSON(req, res, 409, { error: 'Thread already has an active run' });
2620
+ return;
2621
+ }
2622
+
2623
+ let body = '';
2624
+ for await (const chunk of req) { body += chunk; }
2625
+ let parsed = {};
2626
+ try { parsed = body ? JSON.parse(body) : {}; } catch {}
2627
+
2628
+ const { input, agentId } = parsed;
2629
+ if (!input) {
2630
+ sendJSON(req, res, 400, { error: 'Missing input in request body' });
2631
+ return;
2632
+ }
2633
+
2634
+ const resolvedAgentId = agentId || conv.agentId || 'claude-code';
2635
+ const resolvedModel = parsed.model || conv.model || null;
2636
+
2637
+ const session = queries.createSession(threadId, resolvedAgentId, 'pending');
2638
+ const message = queries.createMessage(threadId, 'user', typeof input === 'string' ? input : JSON.stringify(input));
2639
+
2640
+ const waitPromise = new Promise((resolve, reject) => {
2641
+ const checkInterval = setInterval(() => {
2642
+ const updatedSession = queries.getSession(session.id);
2643
+ if (!updatedSession) {
2644
+ clearInterval(checkInterval);
2645
+ reject(new Error('Session not found'));
2646
+ return;
2647
+ }
2648
+ if (['success', 'error', 'interrupted', 'cancelled'].includes(updatedSession.status)) {
2649
+ clearInterval(checkInterval);
2650
+ resolve(updatedSession);
2651
+ }
2652
+ }, 500);
2653
+
2654
+ setTimeout(() => {
2655
+ clearInterval(checkInterval);
2656
+ const updatedSession = queries.getSession(session.id);
2657
+ resolve(updatedSession || session);
2658
+ }, 300000);
2659
+ });
2660
+
2661
+ processMessageWithStreaming(threadId, message.id, session.id, typeof input === 'string' ? input : JSON.stringify(input), resolvedAgentId, resolvedModel);
2662
+
2663
+ try {
2664
+ const completedSession = await waitPromise;
2665
+ sendJSON(req, res, 200, {
2666
+ id: completedSession.id,
2667
+ threadId: threadId,
2668
+ status: completedSession.status,
2669
+ started_at: completedSession.started_at,
2670
+ completed_at: completedSession.completed_at,
2671
+ agentId: resolvedAgentId,
2672
+ output: completedSession.response || null
2673
+ });
2674
+ } catch (err) {
2675
+ sendJSON(req, res, 500, { error: err.message });
2676
+ }
2677
+ return;
2678
+ }
2679
+
2680
+
2360
2681
  const threadRunByIdMatch = pathOnly.match(/^\/api\/threads\/([^/]+)\/runs\/([^/]+)$/);
2361
2682
  if (threadRunByIdMatch) {
2362
2683
  const threadId = threadRunByIdMatch[1];
@@ -2427,6 +2748,106 @@ const server = http.createServer(async (req, res) => {
2427
2748
  }
2428
2749
  }
2429
2750
 
2751
+ const threadRunWaitMatch = pathOnly.match(/^\/api\/threads\/([^\/]+)\/runs\/([^\/]+)\/wait$/);
2752
+ if (threadRunWaitMatch && req.method === 'GET') {
2753
+ const threadId = threadRunWaitMatch[1];
2754
+ const runId = threadRunWaitMatch[2];
2755
+ const session = queries.getSession(runId);
2756
+
2757
+ if (!session || session.conversationId !== threadId) {
2758
+ sendJSON(req, res, 404, { error: 'Run not found' });
2759
+ return;
2760
+ }
2761
+
2762
+ const waitPromise = new Promise((resolve) => {
2763
+ const checkInterval = setInterval(() => {
2764
+ const updatedSession = queries.getSession(runId);
2765
+ if (!updatedSession) {
2766
+ clearInterval(checkInterval);
2767
+ resolve(session);
2768
+ return;
2769
+ }
2770
+ if (['success', 'error', 'interrupted', 'cancelled'].includes(updatedSession.status)) {
2771
+ clearInterval(checkInterval);
2772
+ resolve(updatedSession);
2773
+ }
2774
+ }, 500);
2775
+
2776
+ setTimeout(() => {
2777
+ clearInterval(checkInterval);
2778
+ const updatedSession = queries.getSession(runId) || session;
2779
+ resolve(updatedSession);
2780
+ }, 30000);
2781
+ });
2782
+
2783
+ try {
2784
+ const completedSession = await waitPromise;
2785
+ sendJSON(req, res, 200, {
2786
+ id: completedSession.id,
2787
+ threadId: threadId,
2788
+ status: completedSession.status,
2789
+ started_at: completedSession.started_at,
2790
+ completed_at: completedSession.completed_at,
2791
+ agentId: completedSession.agentId,
2792
+ output: completedSession.response || null
2793
+ });
2794
+ } catch (err) {
2795
+ sendJSON(req, res, 500, { error: err.message });
2796
+ }
2797
+ return;
2798
+ }
2799
+
2800
+ const threadRunStreamMatch = pathOnly.match(/^\/api\/threads\/([^\/]+)\/runs\/([^\/]+)\/stream$/);
2801
+ if (threadRunStreamMatch && req.method === 'GET') {
2802
+ const threadId = threadRunStreamMatch[1];
2803
+ const runId = threadRunStreamMatch[2];
2804
+ const session = queries.getSession(runId);
2805
+
2806
+ if (!session || session.conversationId !== threadId) {
2807
+ sendJSON(req, res, 404, { error: 'Run not found' });
2808
+ return;
2809
+ }
2810
+
2811
+ res.writeHead(200, {
2812
+ 'Content-Type': 'text/event-stream',
2813
+ 'Cache-Control': 'no-cache',
2814
+ 'Connection': 'keep-alive'
2815
+ });
2816
+
2817
+ const chunks = queries.getSessionChunks(runId, 0);
2818
+ for (const chunk of chunks) {
2819
+ res.write(`event: message\ndata: ${JSON.stringify(chunk)}\n\n`);
2820
+ }
2821
+
2822
+ const eventListeners = [];
2823
+ const broadcastListener = (event) => {
2824
+ if (event.sessionId === runId) {
2825
+ if (event.type === 'streaming_progress') {
2826
+ res.write(`event: message\ndata: ${JSON.stringify(event)}\n\n`);
2827
+ } else if (event.type === 'streaming_complete') {
2828
+ res.write(`event: done\ndata: ${JSON.stringify({ status: 'completed' })}\n\n`);
2829
+ res.end();
2830
+ } else if (event.type === 'streaming_error') {
2831
+ res.write(`event: error\ndata: ${JSON.stringify({ error: event.error })}\n\n`);
2832
+ res.end();
2833
+ }
2834
+ }
2835
+ };
2836
+ eventListeners.push(broadcastListener);
2837
+
2838
+ req.on('close', () => {
2839
+ eventListeners.length = 0;
2840
+ });
2841
+
2842
+ if (['success', 'error', 'interrupted', 'cancelled'].includes(session.status)) {
2843
+ res.write(`event: done\ndata: ${JSON.stringify({ status: session.status })}\n\n`);
2844
+ res.end();
2845
+ }
2846
+
2847
+ return;
2848
+ }
2849
+
2850
+
2430
2851
  const threadRunCancelMatch = pathOnly.match(/^\/api\/threads\/([^/]+)\/runs\/([^/]+)\/cancel$/);
2431
2852
  if (threadRunCancelMatch && req.method === 'POST') {
2432
2853
  const threadId = threadRunCancelMatch[1];
@@ -2734,7 +3155,7 @@ const server = http.createServer(async (req, res) => {
2734
3155
  if (pathOnly === '/api/git/push' && req.method === 'POST') {
2735
3156
  try {
2736
3157
  const isWindows = os.platform() === 'win32';
2737
- const gitCommand = isWindows
3158
+ const gitCommand = isWindows
2738
3159
  ? 'git add -A & git commit -m "Auto-commit" & git push'
2739
3160
  : 'git add -A && git commit -m "Auto-commit" && git push';
2740
3161
  execSync(gitCommand, { encoding: 'utf-8', cwd: STARTUP_CWD, shell: isWindows });
@@ -2745,6 +3166,148 @@ const server = http.createServer(async (req, res) => {
2745
3166
  return;
2746
3167
  }
2747
3168
 
3169
+ // ============================================================
3170
+ // THREAD API ENDPOINTS (ACP v0.2.3)
3171
+ // ============================================================
3172
+
3173
+ // POST /threads - Create empty thread
3174
+ if (pathOnly === '/api/threads' && req.method === 'POST') {
3175
+ try {
3176
+ const body = await parseBody(req);
3177
+ const metadata = body.metadata || {};
3178
+ const thread = queries.createThread(metadata);
3179
+ sendJSON(req, res, 201, thread);
3180
+ } catch (err) {
3181
+ sendJSON(req, res, 422, { error: err.message, type: 'validation_error' });
3182
+ }
3183
+ return;
3184
+ }
3185
+
3186
+ // POST /threads/search - Search threads
3187
+ if (pathOnly === '/api/threads/search' && req.method === 'POST') {
3188
+ try {
3189
+ const body = await parseBody(req);
3190
+ const result = queries.searchThreads(body);
3191
+ sendJSON(req, res, 200, result);
3192
+ } catch (err) {
3193
+ sendJSON(req, res, 422, { error: err.message, type: 'validation_error' });
3194
+ }
3195
+ return;
3196
+ }
3197
+
3198
+ // GET /threads/{thread_id} - Get thread by ID
3199
+ const acpThreadMatch = pathOnly.match(/^\/api\/threads\/([a-f0-9-]{36})$/);
3200
+ if (acpThreadMatch && req.method === 'GET') {
3201
+ const threadId = threadByIdMatch[1];
3202
+ try {
3203
+ const thread = queries.getThread(threadId);
3204
+ if (!thread) {
3205
+ sendJSON(req, res, 404, { error: 'Thread not found', type: 'not_found' });
3206
+ } else {
3207
+ sendJSON(req, res, 200, thread);
3208
+ }
3209
+ } catch (err) {
3210
+ sendJSON(req, res, 500, { error: err.message, type: 'internal_error' });
3211
+ }
3212
+ return;
3213
+ }
3214
+
3215
+ // PATCH /threads/{thread_id} - Update thread metadata
3216
+ if (acpThreadMatch && req.method === 'PATCH') {
3217
+ const threadId = threadByIdMatch[1];
3218
+ try {
3219
+ const body = await parseBody(req);
3220
+ const thread = queries.patchThread(threadId, body);
3221
+ sendJSON(req, res, 200, thread);
3222
+ } catch (err) {
3223
+ if (err.message.includes('not found')) {
3224
+ sendJSON(req, res, 404, { error: err.message, type: 'not_found' });
3225
+ } else {
3226
+ sendJSON(req, res, 422, { error: err.message, type: 'validation_error' });
3227
+ }
3228
+ }
3229
+ return;
3230
+ }
3231
+
3232
+ // DELETE /threads/{thread_id} - Delete thread (fail if pending runs exist)
3233
+ if (acpThreadMatch && req.method === 'DELETE') {
3234
+ const threadId = threadByIdMatch[1];
3235
+ try {
3236
+ queries.deleteThread(threadId);
3237
+ res.writeHead(204);
3238
+ res.end();
3239
+ } catch (err) {
3240
+ if (err.message.includes('not found')) {
3241
+ sendJSON(req, res, 404, { error: err.message, type: 'not_found' });
3242
+ } else if (err.message.includes('pending runs')) {
3243
+ sendJSON(req, res, 409, { error: err.message, type: 'conflict' });
3244
+ } else {
3245
+ sendJSON(req, res, 500, { error: err.message, type: 'internal_error' });
3246
+ }
3247
+ }
3248
+ return;
3249
+ }
3250
+
3251
+ // GET /threads/{thread_id}/history - Get thread state history with pagination
3252
+ const threadHistoryMatch = pathOnly.match(/^\/api\/threads\/([a-f0-9-]{36})\/history$/);
3253
+ if (threadHistoryMatch && req.method === 'GET') {
3254
+ const threadId = threadHistoryMatch[1];
3255
+ try {
3256
+ const url = new URL(req.url, `http://${req.headers.host}`);
3257
+ const limit = parseInt(url.searchParams.get('limit') || '50', 10);
3258
+ const before = url.searchParams.get('before') || null;
3259
+
3260
+ // Convert 'before' cursor to offset if needed
3261
+ let offset = 0;
3262
+ if (before) {
3263
+ // Simple cursor-based pagination: before is an offset value
3264
+ offset = parseInt(before, 10);
3265
+ }
3266
+
3267
+ const result = queries.getThreadHistory(threadId, limit, offset);
3268
+
3269
+ // Generate next_cursor if there are more results
3270
+ const response = {
3271
+ states: result.states,
3272
+ next_cursor: result.hasMore ? String(offset + limit) : null
3273
+ };
3274
+
3275
+ sendJSON(req, res, 200, response);
3276
+ } catch (err) {
3277
+ if (err.message.includes('not found')) {
3278
+ sendJSON(req, res, 404, { error: err.message, type: 'not_found' });
3279
+ } else {
3280
+ sendJSON(req, res, 422, { error: err.message, type: 'validation_error' });
3281
+ }
3282
+ }
3283
+ return;
3284
+ }
3285
+
3286
+ // POST /threads/{thread_id}/copy - Copy thread with all states/checkpoints
3287
+ const threadCopyMatch = pathOnly.match(/^\/api\/threads\/([a-f0-9-]{36})\/copy$/);
3288
+ if (threadCopyMatch && req.method === 'POST') {
3289
+ const sourceThreadId = threadCopyMatch[1];
3290
+ try {
3291
+ const body = await parseBody(req);
3292
+ const newThread = queries.copyThread(sourceThreadId);
3293
+
3294
+ // Update metadata if provided
3295
+ if (body.metadata) {
3296
+ const updated = queries.patchThread(newThread.thread_id, { metadata: body.metadata });
3297
+ sendJSON(req, res, 201, updated);
3298
+ } else {
3299
+ sendJSON(req, res, 201, newThread);
3300
+ }
3301
+ } catch (err) {
3302
+ if (err.message.includes('not found')) {
3303
+ sendJSON(req, res, 404, { error: err.message, type: 'not_found' });
3304
+ } else {
3305
+ sendJSON(req, res, 500, { error: err.message, type: 'internal_error' });
3306
+ }
3307
+ }
3308
+ return;
3309
+ }
3310
+
2748
3311
  if (routePath.startsWith('/api/image/')) {
2749
3312
  const imagePath = routePath.slice('/api/image/'.length);
2750
3313
  const decodedPath = decodeURIComponent(imagePath);