agentgui 1.0.382 → 1.0.384

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 +363 -365
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.384",
4
4
  "description": "Multi-agent ACP client with real-time communication",
5
5
  "type": "module",
6
6
  "main": "server.js",
package/server.js CHANGED
@@ -340,6 +340,8 @@ function discoverAgents() {
340
340
 
341
341
  const discoveredAgents = discoverAgents();
342
342
  initializeDescriptors(discoveredAgents);
343
+ const acpQueries = createACPQueries(db, prepare);
344
+ acpQueries.getAgentDescriptor = getAgentDescriptor;
343
345
 
344
346
  const modelCache = new Map();
345
347
 
@@ -1444,9 +1446,9 @@ const server = http.createServer(async (req, res) => {
1444
1446
  return;
1445
1447
  }
1446
1448
 
1447
- const runByIdMatch = pathOnly.match(/^\/api\/runs\/([^/]+)$/);
1448
- if (runByIdMatch) {
1449
- const runId = runByIdMatch[1];
1449
+ const oldRunByIdMatch = pathOnly.match(/^\/api\/runs\/([^/]+)$/);
1450
+ if (oldRunByIdMatch) {
1451
+ const runId = oldRunByIdMatch[1];
1450
1452
  const session = queries.getSession(runId);
1451
1453
 
1452
1454
  if (!session) {
@@ -1511,9 +1513,9 @@ const server = http.createServer(async (req, res) => {
1511
1513
  }
1512
1514
  }
1513
1515
 
1514
- const runCancelMatch = pathOnly.match(/^\/api\/runs\/([^/]+)\/cancel$/);
1515
- if (runCancelMatch && req.method === 'POST') {
1516
- const runId = runCancelMatch[1];
1516
+ const oldRunCancelMatch = pathOnly.match(/^\/api\/runs\/([^/]+)\/cancel$/);
1517
+ if (oldRunCancelMatch && req.method === 'POST') {
1518
+ const runId = oldRunCancelMatch[1];
1517
1519
  const session = queries.getSession(runId);
1518
1520
 
1519
1521
  if (!session) {
@@ -1767,34 +1769,10 @@ const server = http.createServer(async (req, res) => {
1767
1769
  return;
1768
1770
  }
1769
1771
 
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);
1772
+ if (pathOnly === '/api/agents/search' && req.method === 'POST') {
1773
+ const body = await parseBody(req);
1774
+ const result = acpQueries.searchAgents(discoveredAgents, body);
1775
+ sendJSON(req, res, 200, result);
1798
1776
  return;
1799
1777
  }
1800
1778
 
@@ -1915,6 +1893,214 @@ const server = http.createServer(async (req, res) => {
1915
1893
  return;
1916
1894
  }
1917
1895
 
1896
+ if (pathOnly === '/api/runs' && req.method === 'POST') {
1897
+ const body = await parseBody(req);
1898
+ const { agent_id, input, config, webhook_url } = body;
1899
+ if (!agent_id) {
1900
+ sendJSON(req, res, 422, { error: 'agent_id is required' });
1901
+ return;
1902
+ }
1903
+ const agent = discoveredAgents.find(a => a.id === agent_id);
1904
+ if (!agent) {
1905
+ sendJSON(req, res, 404, { error: 'Agent not found' });
1906
+ return;
1907
+ }
1908
+ const run = acpQueries.createRun(agent_id, null, input, config, webhook_url);
1909
+ sendJSON(req, res, 201, run);
1910
+ return;
1911
+ }
1912
+
1913
+ if (pathOnly === '/api/runs/search' && req.method === 'POST') {
1914
+ const body = await parseBody(req);
1915
+ const result = acpQueries.searchRuns(body);
1916
+ sendJSON(req, res, 200, result);
1917
+ return;
1918
+ }
1919
+
1920
+ if (pathOnly === '/api/runs/stream' && req.method === 'POST') {
1921
+ const body = await parseBody(req);
1922
+ const { agent_id, input, config } = body;
1923
+ if (!agent_id) {
1924
+ sendJSON(req, res, 422, { error: 'agent_id is required' });
1925
+ return;
1926
+ }
1927
+ const agent = discoveredAgents.find(a => a.id === agent_id);
1928
+ if (!agent) {
1929
+ sendJSON(req, res, 404, { error: 'Agent not found' });
1930
+ return;
1931
+ }
1932
+ const run = acpQueries.createRun(agent_id, null, input, config);
1933
+ res.writeHead(200, {
1934
+ 'Content-Type': 'text/event-stream',
1935
+ 'Cache-Control': 'no-cache',
1936
+ 'Connection': 'keep-alive'
1937
+ });
1938
+ res.write('data: ' + JSON.stringify({ type: 'run_created', run_id: run.run_id }) + '\n\n');
1939
+ const eventHandler = (eventData) => {
1940
+ if (eventData.sessionId === run.run_id || eventData.conversationId === run.thread_id) {
1941
+ res.write('data: ' + JSON.stringify(eventData) + '\n\n');
1942
+ }
1943
+ };
1944
+ const cleanup = () => {
1945
+ res.end();
1946
+ };
1947
+ req.on('close', cleanup);
1948
+ const statelessThreadId = acpQueries.getRun(run.run_id)?.thread_id;
1949
+ if (statelessThreadId) {
1950
+ const conv = queries.getConversation(statelessThreadId);
1951
+ if (conv && input?.content) {
1952
+ runClaudeWithStreaming(agent_id, statelessThreadId, input.content, config?.model || null).catch(() => {});
1953
+ }
1954
+ }
1955
+ return;
1956
+ }
1957
+
1958
+ if (pathOnly === '/api/runs/wait' && req.method === 'POST') {
1959
+ const body = await parseBody(req);
1960
+ const { agent_id, input, config } = body;
1961
+ if (!agent_id) {
1962
+ sendJSON(req, res, 422, { error: 'agent_id is required' });
1963
+ return;
1964
+ }
1965
+ const agent = discoveredAgents.find(a => a.id === agent_id);
1966
+ if (!agent) {
1967
+ sendJSON(req, res, 404, { error: 'Agent not found' });
1968
+ return;
1969
+ }
1970
+ const run = acpQueries.createRun(agent_id, null, input, config);
1971
+ const statelessThreadId = acpQueries.getRun(run.run_id)?.thread_id;
1972
+ if (statelessThreadId && input?.content) {
1973
+ try {
1974
+ await runClaudeWithStreaming(agent_id, statelessThreadId, input.content, config?.model || null);
1975
+ const finalRun = acpQueries.getRun(run.run_id);
1976
+ sendJSON(req, res, 200, finalRun);
1977
+ } catch (err) {
1978
+ acpQueries.updateRunStatus(run.run_id, 'error');
1979
+ sendJSON(req, res, 500, { error: err.message });
1980
+ }
1981
+ } else {
1982
+ sendJSON(req, res, 200, run);
1983
+ }
1984
+ return;
1985
+ }
1986
+
1987
+ const oldRunByIdMatch1 = pathOnly.match(/^\/api\/runs\/([^/]+)$/);
1988
+ if (oldRunByIdMatch1) {
1989
+ const runId = oldRunByIdMatch1[1];
1990
+
1991
+ if (req.method === 'GET') {
1992
+ const run = acpQueries.getRun(runId);
1993
+ if (!run) {
1994
+ sendJSON(req, res, 404, { error: 'Run not found' });
1995
+ return;
1996
+ }
1997
+ sendJSON(req, res, 200, run);
1998
+ return;
1999
+ }
2000
+
2001
+ if (req.method === 'POST') {
2002
+ const body = await parseBody(req);
2003
+ const run = acpQueries.getRun(runId);
2004
+ if (!run) {
2005
+ sendJSON(req, res, 404, { error: 'Run not found' });
2006
+ return;
2007
+ }
2008
+ if (run.status !== 'pending') {
2009
+ sendJSON(req, res, 409, { error: 'Run is not resumable' });
2010
+ return;
2011
+ }
2012
+ if (body.input?.content && run.thread_id) {
2013
+ runClaudeWithStreaming(run.agent_id, run.thread_id, body.input.content, null).catch(() => {});
2014
+ }
2015
+ sendJSON(req, res, 200, run);
2016
+ return;
2017
+ }
2018
+
2019
+ if (req.method === 'DELETE') {
2020
+ try {
2021
+ acpQueries.deleteRun(runId);
2022
+ res.writeHead(204);
2023
+ res.end();
2024
+ } catch (err) {
2025
+ sendJSON(req, res, 404, { error: 'Run not found' });
2026
+ }
2027
+ return;
2028
+ }
2029
+ }
2030
+
2031
+ const runWaitMatch = pathOnly.match(/^\/api\/runs\/([^/]+)\/wait$/);
2032
+ if (runWaitMatch && req.method === 'GET') {
2033
+ const runId = runWaitMatch[1];
2034
+ const run = acpQueries.getRun(runId);
2035
+ if (!run) {
2036
+ sendJSON(req, res, 404, { error: 'Run not found' });
2037
+ return;
2038
+ }
2039
+ const startTime = Date.now();
2040
+ const pollInterval = setInterval(() => {
2041
+ const currentRun = acpQueries.getRun(runId);
2042
+ if (!currentRun || ['success', 'error', 'cancelled'].includes(currentRun.status) || (Date.now() - startTime) > 30000) {
2043
+ clearInterval(pollInterval);
2044
+ sendJSON(req, res, 200, currentRun || run);
2045
+ }
2046
+ }, 500);
2047
+ req.on('close', () => clearInterval(pollInterval));
2048
+ return;
2049
+ }
2050
+
2051
+ const runStreamMatch = pathOnly.match(/^\/api\/runs\/([^/]+)\/stream$/);
2052
+ if (runStreamMatch && req.method === 'GET') {
2053
+ const runId = runStreamMatch[1];
2054
+ const run = acpQueries.getRun(runId);
2055
+ if (!run) {
2056
+ sendJSON(req, res, 404, { error: 'Run not found' });
2057
+ return;
2058
+ }
2059
+ res.writeHead(200, {
2060
+ 'Content-Type': 'text/event-stream',
2061
+ 'Cache-Control': 'no-cache',
2062
+ 'Connection': 'keep-alive'
2063
+ });
2064
+ res.write('data: ' + JSON.stringify({ type: 'joined', run_id: runId }) + '\n\n');
2065
+ const eventHandler = (eventData) => {
2066
+ if (eventData.sessionId === runId || eventData.conversationId === run.thread_id) {
2067
+ res.write('data: ' + JSON.stringify(eventData) + '\n\n');
2068
+ }
2069
+ };
2070
+ const cleanup = () => {
2071
+ res.end();
2072
+ };
2073
+ req.on('close', cleanup);
2074
+ return;
2075
+ }
2076
+
2077
+ const oldRunCancelMatch1 = pathOnly.match(/^\/api\/runs\/([^/]+)\/cancel$/);
2078
+ if (oldRunCancelMatch1 && req.method === 'POST') {
2079
+ const runId = oldRunCancelMatch1[1];
2080
+ try {
2081
+ const run = acpQueries.cancelRun(runId);
2082
+ const execution = activeExecutions.get(run.thread_id);
2083
+ if (execution?.process) {
2084
+ execution.process.kill('SIGTERM');
2085
+ setTimeout(() => {
2086
+ if (execution.process && !execution.process.killed) {
2087
+ execution.process.kill('SIGKILL');
2088
+ }
2089
+ }, 5000);
2090
+ }
2091
+ sendJSON(req, res, 200, run);
2092
+ } catch (err) {
2093
+ if (err.message === 'Run not found') {
2094
+ sendJSON(req, res, 404, { error: err.message });
2095
+ } else if (err.message.includes('already completed')) {
2096
+ sendJSON(req, res, 409, { error: err.message });
2097
+ } else {
2098
+ sendJSON(req, res, 500, { error: err.message });
2099
+ }
2100
+ }
2101
+ return;
2102
+ }
2103
+
1918
2104
  if (pathOnly === '/api/gemini-oauth/start' && req.method === 'POST') {
1919
2105
  try {
1920
2106
  const result = await startGeminiOAuth(req);
@@ -2139,336 +2325,6 @@ const server = http.createServer(async (req, res) => {
2139
2325
  return;
2140
2326
  }
2141
2327
 
2142
- const threadsMatch = pathOnly.match(/^\/api\/threads$/);
2143
- if (threadsMatch && req.method === 'POST') {
2144
- let body = '';
2145
- for await (const chunk of req) { body += chunk; }
2146
- let parsed = {};
2147
- try { parsed = body ? JSON.parse(body) : {}; } catch {}
2148
-
2149
- const thread = queries.createConversation(parsed.agentId || 'claude-code', parsed.title || 'New Thread', parsed.workingDirectory || STARTUP_CWD);
2150
- sendJSON(req, res, 200, {
2151
- id: thread.id,
2152
- agentId: thread.agentId,
2153
- title: thread.title,
2154
- created_at: thread.created_at,
2155
- status: thread.status,
2156
- state: null
2157
- });
2158
- return;
2159
- }
2160
-
2161
- const threadsSearchMatch = pathOnly.match(/^\/api\/threads\/search$/);
2162
- if (threadsSearchMatch && req.method === 'POST') {
2163
- const conversations = queries.getConversations();
2164
- const threads = conversations.map(c => ({
2165
- id: c.id,
2166
- agentId: c.agentId,
2167
- title: c.title,
2168
- created_at: c.created_at,
2169
- updated_at: c.updated_at,
2170
- status: c.status,
2171
- state: null
2172
- }));
2173
- sendJSON(req, res, 200, threads);
2174
- return;
2175
- }
2176
-
2177
- const threadByIdMatch = pathOnly.match(/^\/api\/threads\/([^/]+)$/);
2178
- if (threadByIdMatch) {
2179
- const threadId = threadByIdMatch[1];
2180
- const conv = queries.getConversation(threadId);
2181
-
2182
- if (!conv) {
2183
- sendJSON(req, res, 404, { error: 'Thread not found' });
2184
- return;
2185
- }
2186
-
2187
- if (req.method === 'GET') {
2188
- sendJSON(req, res, 200, {
2189
- id: conv.id,
2190
- agentId: conv.agentId,
2191
- title: conv.title,
2192
- created_at: conv.created_at,
2193
- updated_at: conv.updated_at,
2194
- status: conv.status,
2195
- state: null
2196
- });
2197
- return;
2198
- }
2199
-
2200
- if (req.method === 'DELETE') {
2201
- const activeEntry = activeExecutions.get(threadId);
2202
- if (activeEntry) {
2203
- sendJSON(req, res, 409, { error: 'Thread has an active run, cannot delete' });
2204
- return;
2205
- }
2206
- queries.deleteConversation(threadId);
2207
- sendJSON(req, res, 204, {});
2208
- return;
2209
- }
2210
-
2211
- if (req.method === 'PATCH') {
2212
- let body = '';
2213
- for await (const chunk of req) { body += chunk; }
2214
- let parsed = {};
2215
- try { parsed = body ? JSON.parse(body) : {}; } catch {}
2216
-
2217
- const updates = {};
2218
- if (parsed.title !== undefined) updates.title = parsed.title;
2219
- if (parsed.state !== undefined) updates.state = parsed.state;
2220
-
2221
- if (Object.keys(updates).length > 0) {
2222
- queries.updateConversation(threadId, updates);
2223
- }
2224
-
2225
- const updated = queries.getConversation(threadId);
2226
- sendJSON(req, res, 200, {
2227
- id: updated.id,
2228
- agentId: updated.agentId,
2229
- title: updated.title,
2230
- created_at: updated.created_at,
2231
- updated_at: updated.updated_at,
2232
- status: updated.status,
2233
- state: updated.state
2234
- });
2235
- return;
2236
- }
2237
- }
2238
-
2239
- const threadHistoryMatch = pathOnly.match(/^\/api\/threads\/([^/]+)\/history$/);
2240
- if (threadHistoryMatch && req.method === 'GET') {
2241
- const threadId = threadHistoryMatch[1];
2242
- const conv = queries.getConversation(threadId);
2243
-
2244
- if (!conv) {
2245
- sendJSON(req, res, 404, { error: 'Thread not found' });
2246
- return;
2247
- }
2248
-
2249
- const limit = parseInt(new URL(req.url, 'http://localhost').searchParams.get('limit') || '10', 10);
2250
- const sessions = queries.getSessionsByConversation(threadId, limit);
2251
-
2252
- const history = sessions.map(s => ({
2253
- checkpoint: s.id,
2254
- state: null,
2255
- created_at: s.started_at,
2256
- runId: s.id,
2257
- status: s.status
2258
- })).reverse();
2259
-
2260
- sendJSON(req, res, 200, history);
2261
- return;
2262
- }
2263
-
2264
- const threadCopyMatch = pathOnly.match(/^\/api\/threads\/([^/]+)\/copy$/);
2265
- if (threadCopyMatch && req.method === 'POST') {
2266
- const threadId = threadCopyMatch[1];
2267
- const original = queries.getConversation(threadId);
2268
-
2269
- if (!original) {
2270
- sendJSON(req, res, 404, { error: 'Thread not found' });
2271
- return;
2272
- }
2273
-
2274
- const newThread = queries.createConversation(original.agentId, original.title + ' (copy)', original.workingDirectory);
2275
-
2276
- const messages = queries.getMessages(threadId, 1000, 0);
2277
- for (const msg of messages) {
2278
- queries.createMessage(newThread.id, msg.role, msg.content);
2279
- }
2280
-
2281
- sendJSON(req, res, 200, {
2282
- id: newThread.id,
2283
- agentId: newThread.agentId,
2284
- title: newThread.title,
2285
- created_at: newThread.created_at,
2286
- status: newThread.status,
2287
- state: null
2288
- });
2289
- return;
2290
- }
2291
-
2292
- const threadRunsMatch = pathOnly.match(/^\/api\/threads\/([^/]+)\/runs$/);
2293
- if (threadRunsMatch) {
2294
- const threadId = threadRunsMatch[1];
2295
- const conv = queries.getConversation(threadId);
2296
-
2297
- if (!conv) {
2298
- sendJSON(req, res, 404, { error: 'Thread not found' });
2299
- return;
2300
- }
2301
-
2302
- if (req.method === 'GET') {
2303
- const limit = parseInt(new URL(req.url, 'http://localhost').searchParams.get('limit') || '10', 10);
2304
- const offset = parseInt(new URL(req.url, 'http://localhost').searchParams.get('offset') || '0', 10);
2305
- const sessions = queries.getSessionsByConversation(threadId, limit, offset);
2306
-
2307
- const runs = sessions.map(s => ({
2308
- id: s.id,
2309
- threadId: s.conversationId,
2310
- status: s.status,
2311
- started_at: s.started_at,
2312
- completed_at: s.completed_at,
2313
- agentId: s.agentId,
2314
- input: null,
2315
- output: null
2316
- }));
2317
-
2318
- sendJSON(req, res, 200, runs);
2319
- return;
2320
- }
2321
-
2322
- if (req.method === 'POST') {
2323
- const activeEntry = activeExecutions.get(threadId);
2324
- if (activeEntry) {
2325
- sendJSON(req, res, 409, { error: 'Thread already has an active run' });
2326
- return;
2327
- }
2328
-
2329
- let body = '';
2330
- for await (const chunk of req) { body += chunk; }
2331
- let parsed = {};
2332
- try { parsed = body ? JSON.parse(body) : {}; } catch {}
2333
-
2334
- const { input, agentId, webhook } = parsed;
2335
- if (!input) {
2336
- sendJSON(req, res, 400, { error: 'Missing input in request body' });
2337
- return;
2338
- }
2339
-
2340
- const resolvedAgentId = agentId || conv.agentId || 'claude-code';
2341
- const resolvedModel = parsed.model || conv.model || null;
2342
- const cwd = conv.workingDirectory || STARTUP_CWD;
2343
-
2344
- const session = queries.createSession(threadId, resolvedAgentId, 'pending');
2345
- const message = queries.createMessage(threadId, 'user', typeof input === 'string' ? input : JSON.stringify(input));
2346
-
2347
- processMessageWithStreaming(threadId, message.id, session.id, typeof input === 'string' ? input : JSON.stringify(input), resolvedAgentId, resolvedModel);
2348
-
2349
- sendJSON(req, res, 200, {
2350
- id: session.id,
2351
- threadId: threadId,
2352
- status: 'pending',
2353
- started_at: session.started_at,
2354
- agentId: resolvedAgentId
2355
- });
2356
- return;
2357
- }
2358
- }
2359
-
2360
- const threadRunByIdMatch = pathOnly.match(/^\/api\/threads\/([^/]+)\/runs\/([^/]+)$/);
2361
- if (threadRunByIdMatch) {
2362
- const threadId = threadRunByIdMatch[1];
2363
- const runId = threadRunByIdMatch[2];
2364
- const session = queries.getSession(runId);
2365
-
2366
- if (!session || session.conversationId !== threadId) {
2367
- sendJSON(req, res, 404, { error: 'Run not found' });
2368
- return;
2369
- }
2370
-
2371
- if (req.method === 'GET') {
2372
- sendJSON(req, res, 200, {
2373
- id: session.id,
2374
- threadId: session.conversationId,
2375
- status: session.status,
2376
- started_at: session.started_at,
2377
- completed_at: session.completed_at,
2378
- agentId: session.agentId,
2379
- input: null,
2380
- output: null
2381
- });
2382
- return;
2383
- }
2384
-
2385
- if (req.method === 'DELETE') {
2386
- queries.deleteSession(runId);
2387
- sendJSON(req, res, 204, {});
2388
- return;
2389
- }
2390
-
2391
- if (req.method === 'POST') {
2392
- if (session.status !== 'interrupted') {
2393
- sendJSON(req, res, 409, { error: 'Can only resume interrupted runs' });
2394
- return;
2395
- }
2396
-
2397
- let body = '';
2398
- for await (const chunk of req) { body += chunk; }
2399
- let parsed = {};
2400
- try { parsed = body ? JSON.parse(body) : {}; } catch {}
2401
-
2402
- const { input } = parsed;
2403
- if (!input) {
2404
- sendJSON(req, res, 400, { error: 'Missing input in request body' });
2405
- return;
2406
- }
2407
-
2408
- const conv = queries.getConversation(threadId);
2409
- const resolvedAgentId = session.agentId || conv.agentId || 'claude-code';
2410
- const resolvedModel = conv?.model || null;
2411
- const cwd = conv?.workingDirectory || STARTUP_CWD;
2412
-
2413
- queries.updateSession(runId, { status: 'pending' });
2414
-
2415
- const message = queries.createMessage(threadId, 'user', typeof input === 'string' ? input : JSON.stringify(input));
2416
-
2417
- processMessageWithStreaming(threadId, message.id, runId, typeof input === 'string' ? input : JSON.stringify(input), resolvedAgentId, resolvedModel);
2418
-
2419
- sendJSON(req, res, 200, {
2420
- id: session.id,
2421
- threadId: threadId,
2422
- status: 'pending',
2423
- started_at: session.started_at,
2424
- agentId: resolvedAgentId
2425
- });
2426
- return;
2427
- }
2428
- }
2429
-
2430
- const threadRunCancelMatch = pathOnly.match(/^\/api\/threads\/([^/]+)\/runs\/([^/]+)\/cancel$/);
2431
- if (threadRunCancelMatch && req.method === 'POST') {
2432
- const threadId = threadRunCancelMatch[1];
2433
- const runId = threadRunCancelMatch[2];
2434
- const session = queries.getSession(runId);
2435
-
2436
- if (!session || session.conversationId !== threadId) {
2437
- sendJSON(req, res, 404, { error: 'Run not found' });
2438
- return;
2439
- }
2440
-
2441
- const entry = activeExecutions.get(threadId);
2442
-
2443
- if (entry && entry.sessionId === runId) {
2444
- const { pid } = entry;
2445
- if (pid) {
2446
- try {
2447
- process.kill(-pid, 'SIGKILL');
2448
- } catch {
2449
- try {
2450
- process.kill(pid, 'SIGKILL');
2451
- } catch (e) {}
2452
- }
2453
- }
2454
- }
2455
-
2456
- queries.updateSession(runId, { status: 'interrupted', completed_at: Date.now() });
2457
- queries.setIsStreaming(threadId, false);
2458
- activeExecutions.delete(threadId);
2459
-
2460
- broadcastSync({
2461
- type: 'streaming_complete',
2462
- sessionId: runId,
2463
- conversationId: threadId,
2464
- interrupted: true,
2465
- timestamp: Date.now()
2466
- });
2467
-
2468
- sendJSON(req, res, 204, {});
2469
- return;
2470
- }
2471
-
2472
2328
  if (pathOnly === '/api/stt' && req.method === 'POST') {
2473
2329
  try {
2474
2330
  const chunks = [];
@@ -2734,7 +2590,7 @@ const server = http.createServer(async (req, res) => {
2734
2590
  if (pathOnly === '/api/git/push' && req.method === 'POST') {
2735
2591
  try {
2736
2592
  const isWindows = os.platform() === 'win32';
2737
- const gitCommand = isWindows
2593
+ const gitCommand = isWindows
2738
2594
  ? 'git add -A & git commit -m "Auto-commit" & git push'
2739
2595
  : 'git add -A && git commit -m "Auto-commit" && git push';
2740
2596
  execSync(gitCommand, { encoding: 'utf-8', cwd: STARTUP_CWD, shell: isWindows });
@@ -2745,6 +2601,148 @@ const server = http.createServer(async (req, res) => {
2745
2601
  return;
2746
2602
  }
2747
2603
 
2604
+ // ============================================================
2605
+ // THREAD API ENDPOINTS (ACP v0.2.3)
2606
+ // ============================================================
2607
+
2608
+ // POST /threads - Create empty thread
2609
+ if (pathOnly === '/api/threads' && req.method === 'POST') {
2610
+ try {
2611
+ const body = await parseBody(req);
2612
+ const metadata = body.metadata || {};
2613
+ const thread = queries.createThread(metadata);
2614
+ sendJSON(req, res, 201, thread);
2615
+ } catch (err) {
2616
+ sendJSON(req, res, 422, { error: err.message, type: 'validation_error' });
2617
+ }
2618
+ return;
2619
+ }
2620
+
2621
+ // POST /threads/search - Search threads
2622
+ if (pathOnly === '/api/threads/search' && req.method === 'POST') {
2623
+ try {
2624
+ const body = await parseBody(req);
2625
+ const result = queries.searchThreads(body);
2626
+ sendJSON(req, res, 200, result);
2627
+ } catch (err) {
2628
+ sendJSON(req, res, 422, { error: err.message, type: 'validation_error' });
2629
+ }
2630
+ return;
2631
+ }
2632
+
2633
+ // GET /threads/{thread_id} - Get thread by ID
2634
+ const acpThreadMatch = pathOnly.match(/^\/api\/threads\/([a-f0-9-]{36})$/);
2635
+ if (acpThreadMatch && req.method === 'GET') {
2636
+ const threadId = threadByIdMatch[1];
2637
+ try {
2638
+ const thread = queries.getThread(threadId);
2639
+ if (!thread) {
2640
+ sendJSON(req, res, 404, { error: 'Thread not found', type: 'not_found' });
2641
+ } else {
2642
+ sendJSON(req, res, 200, thread);
2643
+ }
2644
+ } catch (err) {
2645
+ sendJSON(req, res, 500, { error: err.message, type: 'internal_error' });
2646
+ }
2647
+ return;
2648
+ }
2649
+
2650
+ // PATCH /threads/{thread_id} - Update thread metadata
2651
+ if (acpThreadMatch && req.method === 'PATCH') {
2652
+ const threadId = threadByIdMatch[1];
2653
+ try {
2654
+ const body = await parseBody(req);
2655
+ const thread = queries.patchThread(threadId, body);
2656
+ sendJSON(req, res, 200, thread);
2657
+ } catch (err) {
2658
+ if (err.message.includes('not found')) {
2659
+ sendJSON(req, res, 404, { error: err.message, type: 'not_found' });
2660
+ } else {
2661
+ sendJSON(req, res, 422, { error: err.message, type: 'validation_error' });
2662
+ }
2663
+ }
2664
+ return;
2665
+ }
2666
+
2667
+ // DELETE /threads/{thread_id} - Delete thread (fail if pending runs exist)
2668
+ if (acpThreadMatch && req.method === 'DELETE') {
2669
+ const threadId = threadByIdMatch[1];
2670
+ try {
2671
+ queries.deleteThread(threadId);
2672
+ res.writeHead(204);
2673
+ res.end();
2674
+ } catch (err) {
2675
+ if (err.message.includes('not found')) {
2676
+ sendJSON(req, res, 404, { error: err.message, type: 'not_found' });
2677
+ } else if (err.message.includes('pending runs')) {
2678
+ sendJSON(req, res, 409, { error: err.message, type: 'conflict' });
2679
+ } else {
2680
+ sendJSON(req, res, 500, { error: err.message, type: 'internal_error' });
2681
+ }
2682
+ }
2683
+ return;
2684
+ }
2685
+
2686
+ // GET /threads/{thread_id}/history - Get thread state history with pagination
2687
+ const threadHistoryMatch = pathOnly.match(/^\/api\/threads\/([a-f0-9-]{36})\/history$/);
2688
+ if (threadHistoryMatch && req.method === 'GET') {
2689
+ const threadId = threadHistoryMatch[1];
2690
+ try {
2691
+ const url = new URL(req.url, `http://${req.headers.host}`);
2692
+ const limit = parseInt(url.searchParams.get('limit') || '50', 10);
2693
+ const before = url.searchParams.get('before') || null;
2694
+
2695
+ // Convert 'before' cursor to offset if needed
2696
+ let offset = 0;
2697
+ if (before) {
2698
+ // Simple cursor-based pagination: before is an offset value
2699
+ offset = parseInt(before, 10);
2700
+ }
2701
+
2702
+ const result = queries.getThreadHistory(threadId, limit, offset);
2703
+
2704
+ // Generate next_cursor if there are more results
2705
+ const response = {
2706
+ states: result.states,
2707
+ next_cursor: result.hasMore ? String(offset + limit) : null
2708
+ };
2709
+
2710
+ sendJSON(req, res, 200, response);
2711
+ } catch (err) {
2712
+ if (err.message.includes('not found')) {
2713
+ sendJSON(req, res, 404, { error: err.message, type: 'not_found' });
2714
+ } else {
2715
+ sendJSON(req, res, 422, { error: err.message, type: 'validation_error' });
2716
+ }
2717
+ }
2718
+ return;
2719
+ }
2720
+
2721
+ // POST /threads/{thread_id}/copy - Copy thread with all states/checkpoints
2722
+ const threadCopyMatch = pathOnly.match(/^\/api\/threads\/([a-f0-9-]{36})\/copy$/);
2723
+ if (threadCopyMatch && req.method === 'POST') {
2724
+ const sourceThreadId = threadCopyMatch[1];
2725
+ try {
2726
+ const body = await parseBody(req);
2727
+ const newThread = queries.copyThread(sourceThreadId);
2728
+
2729
+ // Update metadata if provided
2730
+ if (body.metadata) {
2731
+ const updated = queries.patchThread(newThread.thread_id, { metadata: body.metadata });
2732
+ sendJSON(req, res, 201, updated);
2733
+ } else {
2734
+ sendJSON(req, res, 201, newThread);
2735
+ }
2736
+ } catch (err) {
2737
+ if (err.message.includes('not found')) {
2738
+ sendJSON(req, res, 404, { error: err.message, type: 'not_found' });
2739
+ } else {
2740
+ sendJSON(req, res, 500, { error: err.message, type: 'internal_error' });
2741
+ }
2742
+ }
2743
+ return;
2744
+ }
2745
+
2748
2746
  if (routePath.startsWith('/api/image/')) {
2749
2747
  const imagePath = routePath.slice('/api/image/'.length);
2750
2748
  const decodedPath = decodeURIComponent(imagePath);