agentgui 1.0.238 → 1.0.240

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.
package/database.js CHANGED
@@ -308,6 +308,11 @@ export const queries = {
308
308
  return stmt.all('deleted');
309
309
  },
310
310
 
311
+ getConversations() {
312
+ const stmt = prep('SELECT * FROM conversations WHERE status != ? ORDER BY updated_at DESC');
313
+ return stmt.all('deleted');
314
+ },
315
+
311
316
  updateConversation(id, data) {
312
317
  const conv = this.getConversation(id);
313
318
  if (!conv) return null;
@@ -568,6 +573,28 @@ export const queries = {
568
573
  return stmt.all();
569
574
  },
570
575
 
576
+ getSessionsByConversation(conversationId, limit = 10, offset = 0) {
577
+ const stmt = prep(
578
+ 'SELECT * FROM sessions WHERE conversationId = ? ORDER BY started_at DESC LIMIT ? OFFSET ?'
579
+ );
580
+ return stmt.all(conversationId, limit, offset);
581
+ },
582
+
583
+ getAllSessions(limit = 100) {
584
+ const stmt = prep(
585
+ 'SELECT * FROM sessions ORDER BY started_at DESC LIMIT ?'
586
+ );
587
+ return stmt.all(limit);
588
+ },
589
+
590
+ deleteSession(id) {
591
+ const stmt = prep('DELETE FROM sessions WHERE id = ?');
592
+ const result = stmt.run(id);
593
+ prep('DELETE FROM chunks WHERE sessionId = ?').run(id);
594
+ prep('DELETE FROM events WHERE sessionId = ?').run(id);
595
+ return result.changes || 0;
596
+ },
597
+
571
598
  createEvent(type, data, conversationId = null, sessionId = null) {
572
599
  const id = generateId('evt');
573
600
  const now = Date.now();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentgui",
3
- "version": "1.0.238",
3
+ "version": "1.0.240",
4
4
  "description": "Multi-agent ACP client with real-time communication",
5
5
  "type": "module",
6
6
  "main": "server.js",
package/server.js CHANGED
@@ -1217,7 +1217,7 @@ const server = http.createServer(async (req, res) => {
1217
1217
  startTime: Date.now(),
1218
1218
  duration: 0,
1219
1219
  eventCount: 0
1220
- }
1220
+ }
1221
1221
  };
1222
1222
 
1223
1223
  if (filterType) {
@@ -1231,6 +1231,163 @@ const server = http.createServer(async (req, res) => {
1231
1231
  return;
1232
1232
  }
1233
1233
 
1234
+ const runsMatch = pathOnly.match(/^\/api\/runs$/);
1235
+ if (runsMatch && req.method === 'POST') {
1236
+ let body = '';
1237
+ for await (const chunk of req) { body += chunk; }
1238
+ let parsed = {};
1239
+ try { parsed = body ? JSON.parse(body) : {}; } catch {}
1240
+
1241
+ const { input, agentId, webhook } = parsed;
1242
+ if (!input) {
1243
+ sendJSON(req, res, 400, { error: 'Missing input in request body' });
1244
+ return;
1245
+ }
1246
+
1247
+ const resolvedAgentId = agentId || 'claude-code';
1248
+ const resolvedModel = parsed.model || null;
1249
+ const cwd = parsed.workingDirectory || STARTUP_CWD;
1250
+
1251
+ const thread = queries.createConversation(resolvedAgentId, 'Stateless Run', cwd);
1252
+ const session = queries.createSession(thread.id, resolvedAgentId, 'pending');
1253
+ const message = queries.createMessage(thread.id, 'user', typeof input === 'string' ? input : JSON.stringify(input));
1254
+
1255
+ processMessageWithStreaming(thread.id, message.id, session.id, typeof input === 'string' ? input : JSON.stringify(input), resolvedAgentId, resolvedModel);
1256
+
1257
+ sendJSON(req, res, 200, {
1258
+ id: session.id,
1259
+ status: 'pending',
1260
+ started_at: session.started_at,
1261
+ agentId: resolvedAgentId
1262
+ });
1263
+ return;
1264
+ }
1265
+
1266
+ const runsSearchMatch = pathOnly.match(/^\/api\/runs\/search$/);
1267
+ if (runsSearchMatch && req.method === 'POST') {
1268
+ const sessions = queries.getAllSessions();
1269
+ const runs = sessions.slice(0, 50).map(s => ({
1270
+ id: s.id,
1271
+ status: s.status,
1272
+ started_at: s.started_at,
1273
+ completed_at: s.completed_at,
1274
+ agentId: s.agentId,
1275
+ input: null,
1276
+ output: null
1277
+ })).reverse();
1278
+ sendJSON(req, res, 200, runs);
1279
+ return;
1280
+ }
1281
+
1282
+ const runByIdMatch = pathOnly.match(/^\/api\/runs\/([^/]+)$/);
1283
+ if (runByIdMatch) {
1284
+ const runId = runByIdMatch[1];
1285
+ const session = queries.getSession(runId);
1286
+
1287
+ if (!session) {
1288
+ sendJSON(req, res, 404, { error: 'Run not found' });
1289
+ return;
1290
+ }
1291
+
1292
+ if (req.method === 'GET') {
1293
+ sendJSON(req, res, 200, {
1294
+ id: session.id,
1295
+ status: session.status,
1296
+ started_at: session.started_at,
1297
+ completed_at: session.completed_at,
1298
+ agentId: session.agentId,
1299
+ input: null,
1300
+ output: null
1301
+ });
1302
+ return;
1303
+ }
1304
+
1305
+ if (req.method === 'DELETE') {
1306
+ queries.deleteSession(runId);
1307
+ sendJSON(req, res, 204, {});
1308
+ return;
1309
+ }
1310
+
1311
+ if (req.method === 'POST') {
1312
+ if (session.status !== 'interrupted') {
1313
+ sendJSON(req, res, 409, { error: 'Can only resume interrupted runs' });
1314
+ return;
1315
+ }
1316
+
1317
+ let body = '';
1318
+ for await (const chunk of req) { body += chunk; }
1319
+ let parsed = {};
1320
+ try { parsed = body ? JSON.parse(body) : {}; } catch {}
1321
+
1322
+ const { input } = parsed;
1323
+ if (!input) {
1324
+ sendJSON(req, res, 400, { error: 'Missing input in request body' });
1325
+ return;
1326
+ }
1327
+
1328
+ const conv = queries.getConversation(session.conversationId);
1329
+ const resolvedAgentId = session.agentId || conv?.agentId || 'claude-code';
1330
+ const resolvedModel = conv?.model || null;
1331
+ const cwd = conv?.workingDirectory || STARTUP_CWD;
1332
+
1333
+ queries.updateSession(runId, { status: 'pending' });
1334
+
1335
+ const message = queries.createMessage(session.conversationId, 'user', typeof input === 'string' ? input : JSON.stringify(input));
1336
+
1337
+ processMessageWithStreaming(session.conversationId, message.id, runId, typeof input === 'string' ? input : JSON.stringify(input), resolvedAgentId, resolvedModel);
1338
+
1339
+ sendJSON(req, res, 200, {
1340
+ id: session.id,
1341
+ status: 'pending',
1342
+ started_at: session.started_at,
1343
+ agentId: resolvedAgentId
1344
+ });
1345
+ return;
1346
+ }
1347
+ }
1348
+
1349
+ const runCancelMatch = pathOnly.match(/^\/api\/runs\/([^/]+)\/cancel$/);
1350
+ if (runCancelMatch && req.method === 'POST') {
1351
+ const runId = runCancelMatch[1];
1352
+ const session = queries.getSession(runId);
1353
+
1354
+ if (!session) {
1355
+ sendJSON(req, res, 404, { error: 'Run not found' });
1356
+ return;
1357
+ }
1358
+
1359
+ const conversationId = session.conversationId;
1360
+ const entry = activeExecutions.get(conversationId);
1361
+
1362
+ if (entry && entry.sessionId === runId) {
1363
+ const { pid } = entry;
1364
+ if (pid) {
1365
+ try {
1366
+ process.kill(-pid, 'SIGKILL');
1367
+ } catch {
1368
+ try {
1369
+ process.kill(pid, 'SIGKILL');
1370
+ } catch (e) {}
1371
+ }
1372
+ }
1373
+ }
1374
+
1375
+ queries.updateSession(runId, { status: 'interrupted', completed_at: Date.now() });
1376
+ queries.setIsStreaming(conversationId, false);
1377
+ activeExecutions.delete(conversationId);
1378
+
1379
+ broadcastSync({
1380
+ type: 'streaming_complete',
1381
+ sessionId: runId,
1382
+ conversationId,
1383
+ interrupted: true,
1384
+ timestamp: Date.now()
1385
+ });
1386
+
1387
+ sendJSON(req, res, 204, {});
1388
+ return;
1389
+ }
1390
+
1234
1391
  const scriptsMatch = pathOnly.match(/^\/api\/conversations\/([^/]+)\/scripts$/);
1235
1392
  if (scriptsMatch && req.method === 'GET') {
1236
1393
  const conv = queries.getConversation(scriptsMatch[1]);
@@ -1307,11 +1464,227 @@ const server = http.createServer(async (req, res) => {
1307
1464
  return;
1308
1465
  }
1309
1466
 
1467
+ const cancelRunMatch = pathOnly.match(/^\/api\/conversations\/([^/]+)\/cancel$/);
1468
+ if (cancelRunMatch && req.method === 'POST') {
1469
+ const conversationId = cancelRunMatch[1];
1470
+ const entry = activeExecutions.get(conversationId);
1471
+
1472
+ if (!entry) {
1473
+ sendJSON(req, res, 404, { error: 'No active execution to cancel' });
1474
+ return;
1475
+ }
1476
+
1477
+ const { pid, sessionId } = entry;
1478
+
1479
+ if (pid) {
1480
+ try {
1481
+ process.kill(-pid, 'SIGKILL');
1482
+ } catch {
1483
+ try {
1484
+ process.kill(pid, 'SIGKILL');
1485
+ } catch (e) {}
1486
+ }
1487
+ }
1488
+
1489
+ if (sessionId) {
1490
+ queries.updateSession(sessionId, {
1491
+ status: 'interrupted',
1492
+ completed_at: Date.now()
1493
+ });
1494
+ }
1495
+
1496
+ queries.setIsStreaming(conversationId, false);
1497
+ activeExecutions.delete(conversationId);
1498
+
1499
+ broadcastSync({
1500
+ type: 'streaming_complete',
1501
+ sessionId,
1502
+ conversationId,
1503
+ interrupted: true,
1504
+ timestamp: Date.now()
1505
+ });
1506
+
1507
+ sendJSON(req, res, 200, { ok: true, cancelled: true, conversationId, sessionId });
1508
+ return;
1509
+ }
1510
+
1511
+ const resumeRunMatch = pathOnly.match(/^\/api\/conversations\/([^/]+)\/resume$/);
1512
+ if (resumeRunMatch && req.method === 'POST') {
1513
+ const conversationId = resumeRunMatch[1];
1514
+ const conv = queries.getConversation(conversationId);
1515
+
1516
+ if (!conv) {
1517
+ sendJSON(req, res, 404, { error: 'Conversation not found' });
1518
+ return;
1519
+ }
1520
+
1521
+ const activeEntry = activeExecutions.get(conversationId);
1522
+ if (activeEntry) {
1523
+ sendJSON(req, res, 409, { error: 'Conversation already has an active execution' });
1524
+ return;
1525
+ }
1526
+
1527
+ let body = '';
1528
+ for await (const chunk of req) { body += chunk; }
1529
+ let parsed = {};
1530
+ try { parsed = body ? JSON.parse(body) : {}; } catch {}
1531
+
1532
+ const { content, agentId } = parsed;
1533
+ if (!content) {
1534
+ sendJSON(req, res, 400, { error: 'Missing content in request body' });
1535
+ return;
1536
+ }
1537
+
1538
+ const resolvedAgentId = agentId || conv.agentId || 'claude-code';
1539
+ const resolvedModel = parsed.model || conv.model || null;
1540
+ const cwd = conv.workingDirectory || STARTUP_CWD;
1541
+
1542
+ const session = queries.createSession(conversationId, resolvedAgentId, 'pending');
1543
+
1544
+ const message = queries.createMessage(conversationId, 'user', content);
1545
+
1546
+ processMessageWithStreaming(conversationId, message.id, session.id, content, resolvedAgentId, resolvedModel);
1547
+
1548
+ sendJSON(req, res, 200, {
1549
+ ok: true,
1550
+ conversationId,
1551
+ sessionId: session.id,
1552
+ messageId: message.id,
1553
+ resumed: true
1554
+ });
1555
+ return;
1556
+ }
1557
+
1558
+ const injectMatch = pathOnly.match(/^\/api\/conversations\/([^/]+)\/inject$/);
1559
+ if (injectMatch && req.method === 'POST') {
1560
+ const conversationId = injectMatch[1];
1561
+ const conv = queries.getConversation(conversationId);
1562
+
1563
+ if (!conv) {
1564
+ sendJSON(req, res, 404, { error: 'Conversation not found' });
1565
+ return;
1566
+ }
1567
+
1568
+ let body = '';
1569
+ for await (const chunk of req) { body += chunk; }
1570
+ let parsed = {};
1571
+ try { parsed = body ? JSON.parse(body) : {}; } catch {}
1572
+
1573
+ const { content, eager } = parsed;
1574
+ if (!content) {
1575
+ sendJSON(req, res, 400, { error: 'Missing content in request body' });
1576
+ return;
1577
+ }
1578
+
1579
+ const entry = activeExecutions.get(conversationId);
1580
+
1581
+ if (entry && eager) {
1582
+ sendJSON(req, res, 409, { error: 'Cannot eagerly inject while execution is running - message queued' });
1583
+ return;
1584
+ }
1585
+
1586
+ const message = queries.createMessage(conversationId, 'user', '[INJECTED] ' + content);
1587
+
1588
+ if (!entry) {
1589
+ const resolvedAgentId = conv.agentId || 'claude-code';
1590
+ const resolvedModel = conv.model || null;
1591
+ const cwd = conv.workingDirectory || STARTUP_CWD;
1592
+ const session = queries.createSession(conversationId, resolvedAgentId, 'pending');
1593
+ processMessageWithStreaming(conversationId, message.id, session.id, message.content, resolvedAgentId, resolvedModel);
1594
+ }
1595
+
1596
+ sendJSON(req, res, 200, { ok: true, injected: true, conversationId, messageId: message.id });
1597
+ return;
1598
+ }
1599
+
1310
1600
  if (pathOnly === '/api/agents' && req.method === 'GET') {
1311
1601
  sendJSON(req, res, 200, { agents: discoveredAgents });
1312
1602
  return;
1313
1603
  }
1314
1604
 
1605
+ const agentsSearchMatch = pathOnly.match(/^\/api\/agents\/search$/);
1606
+ if (agentsSearchMatch && req.method === 'POST') {
1607
+ let body = '';
1608
+ for await (const chunk of req) { body += chunk; }
1609
+ let parsed = {};
1610
+ try { parsed = body ? JSON.parse(body) : {}; } catch {}
1611
+
1612
+ const { query } = parsed;
1613
+ let results = discoveredAgents;
1614
+
1615
+ if (query) {
1616
+ const q = query.toLowerCase();
1617
+ results = discoveredAgents.filter(a =>
1618
+ a.name.toLowerCase().includes(q) ||
1619
+ a.id.toLowerCase().includes(q) ||
1620
+ (a.description && a.description.toLowerCase().includes(q))
1621
+ );
1622
+ }
1623
+
1624
+ const agents = results.map(a => ({
1625
+ id: a.id,
1626
+ name: a.name,
1627
+ description: a.description || '',
1628
+ icon: a.icon || null,
1629
+ status: 'available'
1630
+ }));
1631
+
1632
+ sendJSON(req, res, 200, agents);
1633
+ return;
1634
+ }
1635
+
1636
+ const agentByIdMatch = pathOnly.match(/^\/api\/agents\/([^/]+)$/);
1637
+ if (agentByIdMatch && req.method === 'GET') {
1638
+ const agentId = agentByIdMatch[1];
1639
+ const agent = discoveredAgents.find(a => a.id === agentId);
1640
+
1641
+ if (!agent) {
1642
+ sendJSON(req, res, 404, { error: 'Agent not found' });
1643
+ return;
1644
+ }
1645
+
1646
+ sendJSON(req, res, 200, {
1647
+ id: agent.id,
1648
+ name: agent.name,
1649
+ description: agent.description || '',
1650
+ icon: agent.icon || null,
1651
+ status: 'available'
1652
+ });
1653
+ return;
1654
+ }
1655
+
1656
+ const agentDescriptorMatch = pathOnly.match(/^\/api\/agents\/([^/]+)\/descriptor$/);
1657
+ if (agentDescriptorMatch && req.method === 'GET') {
1658
+ const agentId = agentDescriptorMatch[1];
1659
+ const agent = discoveredAgents.find(a => a.id === agentId);
1660
+
1661
+ if (!agent) {
1662
+ sendJSON(req, res, 404, { error: 'Agent not found' });
1663
+ return;
1664
+ }
1665
+
1666
+ sendJSON(req, res, 200, {
1667
+ agentId: agent.id,
1668
+ agentName: agent.name,
1669
+ protocol: agent.protocol || 'direct',
1670
+ capabilities: {
1671
+ streaming: true,
1672
+ cancel: true,
1673
+ resume: agent.protocol === 'direct',
1674
+ stateful: true
1675
+ },
1676
+ inputSchema: {
1677
+ type: 'object',
1678
+ properties: {
1679
+ content: { type: 'string', description: 'The prompt to send to the agent' }
1680
+ },
1681
+ required: ['content']
1682
+ },
1683
+ stateFormat: 'opaque'
1684
+ });
1685
+ return;
1686
+ }
1687
+
1315
1688
  const modelsMatch = pathOnly.match(/^\/api\/agents\/([^/]+)\/models$/);
1316
1689
  if (modelsMatch && req.method === 'GET') {
1317
1690
  const agentId = modelsMatch[1];
@@ -1584,6 +1957,336 @@ const server = http.createServer(async (req, res) => {
1584
1957
  return;
1585
1958
  }
1586
1959
 
1960
+ const threadsMatch = pathOnly.match(/^\/api\/threads$/);
1961
+ if (threadsMatch && req.method === 'POST') {
1962
+ let body = '';
1963
+ for await (const chunk of req) { body += chunk; }
1964
+ let parsed = {};
1965
+ try { parsed = body ? JSON.parse(body) : {}; } catch {}
1966
+
1967
+ const thread = queries.createConversation(parsed.agentId || 'claude-code', parsed.title || 'New Thread', parsed.workingDirectory || STARTUP_CWD);
1968
+ sendJSON(req, res, 200, {
1969
+ id: thread.id,
1970
+ agentId: thread.agentId,
1971
+ title: thread.title,
1972
+ created_at: thread.created_at,
1973
+ status: thread.status,
1974
+ state: null
1975
+ });
1976
+ return;
1977
+ }
1978
+
1979
+ const threadsSearchMatch = pathOnly.match(/^\/api\/threads\/search$/);
1980
+ if (threadsSearchMatch && req.method === 'POST') {
1981
+ const conversations = queries.getConversations();
1982
+ const threads = conversations.map(c => ({
1983
+ id: c.id,
1984
+ agentId: c.agentId,
1985
+ title: c.title,
1986
+ created_at: c.created_at,
1987
+ updated_at: c.updated_at,
1988
+ status: c.status,
1989
+ state: null
1990
+ }));
1991
+ sendJSON(req, res, 200, threads);
1992
+ return;
1993
+ }
1994
+
1995
+ const threadByIdMatch = pathOnly.match(/^\/api\/threads\/([^/]+)$/);
1996
+ if (threadByIdMatch) {
1997
+ const threadId = threadByIdMatch[1];
1998
+ const conv = queries.getConversation(threadId);
1999
+
2000
+ if (!conv) {
2001
+ sendJSON(req, res, 404, { error: 'Thread not found' });
2002
+ return;
2003
+ }
2004
+
2005
+ if (req.method === 'GET') {
2006
+ sendJSON(req, res, 200, {
2007
+ id: conv.id,
2008
+ agentId: conv.agentId,
2009
+ title: conv.title,
2010
+ created_at: conv.created_at,
2011
+ updated_at: conv.updated_at,
2012
+ status: conv.status,
2013
+ state: null
2014
+ });
2015
+ return;
2016
+ }
2017
+
2018
+ if (req.method === 'DELETE') {
2019
+ const activeEntry = activeExecutions.get(threadId);
2020
+ if (activeEntry) {
2021
+ sendJSON(req, res, 409, { error: 'Thread has an active run, cannot delete' });
2022
+ return;
2023
+ }
2024
+ queries.deleteConversation(threadId);
2025
+ sendJSON(req, res, 204, {});
2026
+ return;
2027
+ }
2028
+
2029
+ if (req.method === 'PATCH') {
2030
+ let body = '';
2031
+ for await (const chunk of req) { body += chunk; }
2032
+ let parsed = {};
2033
+ try { parsed = body ? JSON.parse(body) : {}; } catch {}
2034
+
2035
+ const updates = {};
2036
+ if (parsed.title !== undefined) updates.title = parsed.title;
2037
+ if (parsed.state !== undefined) updates.state = parsed.state;
2038
+
2039
+ if (Object.keys(updates).length > 0) {
2040
+ queries.updateConversation(threadId, updates);
2041
+ }
2042
+
2043
+ const updated = queries.getConversation(threadId);
2044
+ sendJSON(req, res, 200, {
2045
+ id: updated.id,
2046
+ agentId: updated.agentId,
2047
+ title: updated.title,
2048
+ created_at: updated.created_at,
2049
+ updated_at: updated.updated_at,
2050
+ status: updated.status,
2051
+ state: updated.state
2052
+ });
2053
+ return;
2054
+ }
2055
+ }
2056
+
2057
+ const threadHistoryMatch = pathOnly.match(/^\/api\/threads\/([^/]+)\/history$/);
2058
+ if (threadHistoryMatch && req.method === 'GET') {
2059
+ const threadId = threadHistoryMatch[1];
2060
+ const conv = queries.getConversation(threadId);
2061
+
2062
+ if (!conv) {
2063
+ sendJSON(req, res, 404, { error: 'Thread not found' });
2064
+ return;
2065
+ }
2066
+
2067
+ const limit = parseInt(new URL(req.url, 'http://localhost').searchParams.get('limit') || '10', 10);
2068
+ const sessions = queries.getSessionsByConversation(threadId, limit);
2069
+
2070
+ const history = sessions.map(s => ({
2071
+ checkpoint: s.id,
2072
+ state: null,
2073
+ created_at: s.started_at,
2074
+ runId: s.id,
2075
+ status: s.status
2076
+ })).reverse();
2077
+
2078
+ sendJSON(req, res, 200, history);
2079
+ return;
2080
+ }
2081
+
2082
+ const threadCopyMatch = pathOnly.match(/^\/api\/threads\/([^/]+)\/copy$/);
2083
+ if (threadCopyMatch && req.method === 'POST') {
2084
+ const threadId = threadCopyMatch[1];
2085
+ const original = queries.getConversation(threadId);
2086
+
2087
+ if (!original) {
2088
+ sendJSON(req, res, 404, { error: 'Thread not found' });
2089
+ return;
2090
+ }
2091
+
2092
+ const newThread = queries.createConversation(original.agentId, original.title + ' (copy)', original.workingDirectory);
2093
+
2094
+ const messages = queries.getMessages(threadId, 1000, 0);
2095
+ for (const msg of messages) {
2096
+ queries.createMessage(newThread.id, msg.role, msg.content);
2097
+ }
2098
+
2099
+ sendJSON(req, res, 200, {
2100
+ id: newThread.id,
2101
+ agentId: newThread.agentId,
2102
+ title: newThread.title,
2103
+ created_at: newThread.created_at,
2104
+ status: newThread.status,
2105
+ state: null
2106
+ });
2107
+ return;
2108
+ }
2109
+
2110
+ const threadRunsMatch = pathOnly.match(/^\/api\/threads\/([^/]+)\/runs$/);
2111
+ if (threadRunsMatch) {
2112
+ const threadId = threadRunsMatch[1];
2113
+ const conv = queries.getConversation(threadId);
2114
+
2115
+ if (!conv) {
2116
+ sendJSON(req, res, 404, { error: 'Thread not found' });
2117
+ return;
2118
+ }
2119
+
2120
+ if (req.method === 'GET') {
2121
+ const limit = parseInt(new URL(req.url, 'http://localhost').searchParams.get('limit') || '10', 10);
2122
+ const offset = parseInt(new URL(req.url, 'http://localhost').searchParams.get('offset') || '0', 10);
2123
+ const sessions = queries.getSessionsByConversation(threadId, limit, offset);
2124
+
2125
+ const runs = sessions.map(s => ({
2126
+ id: s.id,
2127
+ threadId: s.conversationId,
2128
+ status: s.status,
2129
+ started_at: s.started_at,
2130
+ completed_at: s.completed_at,
2131
+ agentId: s.agentId,
2132
+ input: null,
2133
+ output: null
2134
+ }));
2135
+
2136
+ sendJSON(req, res, 200, runs);
2137
+ return;
2138
+ }
2139
+
2140
+ if (req.method === 'POST') {
2141
+ const activeEntry = activeExecutions.get(threadId);
2142
+ if (activeEntry) {
2143
+ sendJSON(req, res, 409, { error: 'Thread already has an active run' });
2144
+ return;
2145
+ }
2146
+
2147
+ let body = '';
2148
+ for await (const chunk of req) { body += chunk; }
2149
+ let parsed = {};
2150
+ try { parsed = body ? JSON.parse(body) : {}; } catch {}
2151
+
2152
+ const { input, agentId, webhook } = parsed;
2153
+ if (!input) {
2154
+ sendJSON(req, res, 400, { error: 'Missing input in request body' });
2155
+ return;
2156
+ }
2157
+
2158
+ const resolvedAgentId = agentId || conv.agentId || 'claude-code';
2159
+ const resolvedModel = parsed.model || conv.model || null;
2160
+ const cwd = conv.workingDirectory || STARTUP_CWD;
2161
+
2162
+ const session = queries.createSession(threadId, resolvedAgentId, 'pending');
2163
+ const message = queries.createMessage(threadId, 'user', typeof input === 'string' ? input : JSON.stringify(input));
2164
+
2165
+ processMessageWithStreaming(threadId, message.id, session.id, typeof input === 'string' ? input : JSON.stringify(input), resolvedAgentId, resolvedModel);
2166
+
2167
+ sendJSON(req, res, 200, {
2168
+ id: session.id,
2169
+ threadId: threadId,
2170
+ status: 'pending',
2171
+ started_at: session.started_at,
2172
+ agentId: resolvedAgentId
2173
+ });
2174
+ return;
2175
+ }
2176
+ }
2177
+
2178
+ const threadRunByIdMatch = pathOnly.match(/^\/api\/threads\/([^/]+)\/runs\/([^/]+)$/);
2179
+ if (threadRunByIdMatch) {
2180
+ const threadId = threadRunByIdMatch[1];
2181
+ const runId = threadRunByIdMatch[2];
2182
+ const session = queries.getSession(runId);
2183
+
2184
+ if (!session || session.conversationId !== threadId) {
2185
+ sendJSON(req, res, 404, { error: 'Run not found' });
2186
+ return;
2187
+ }
2188
+
2189
+ if (req.method === 'GET') {
2190
+ sendJSON(req, res, 200, {
2191
+ id: session.id,
2192
+ threadId: session.conversationId,
2193
+ status: session.status,
2194
+ started_at: session.started_at,
2195
+ completed_at: session.completed_at,
2196
+ agentId: session.agentId,
2197
+ input: null,
2198
+ output: null
2199
+ });
2200
+ return;
2201
+ }
2202
+
2203
+ if (req.method === 'DELETE') {
2204
+ queries.deleteSession(runId);
2205
+ sendJSON(req, res, 204, {});
2206
+ return;
2207
+ }
2208
+
2209
+ if (req.method === 'POST') {
2210
+ if (session.status !== 'interrupted') {
2211
+ sendJSON(req, res, 409, { error: 'Can only resume interrupted runs' });
2212
+ return;
2213
+ }
2214
+
2215
+ let body = '';
2216
+ for await (const chunk of req) { body += chunk; }
2217
+ let parsed = {};
2218
+ try { parsed = body ? JSON.parse(body) : {}; } catch {}
2219
+
2220
+ const { input } = parsed;
2221
+ if (!input) {
2222
+ sendJSON(req, res, 400, { error: 'Missing input in request body' });
2223
+ return;
2224
+ }
2225
+
2226
+ const conv = queries.getConversation(threadId);
2227
+ const resolvedAgentId = session.agentId || conv.agentId || 'claude-code';
2228
+ const resolvedModel = conv?.model || null;
2229
+ const cwd = conv?.workingDirectory || STARTUP_CWD;
2230
+
2231
+ queries.updateSession(runId, { status: 'pending' });
2232
+
2233
+ const message = queries.createMessage(threadId, 'user', typeof input === 'string' ? input : JSON.stringify(input));
2234
+
2235
+ processMessageWithStreaming(threadId, message.id, runId, typeof input === 'string' ? input : JSON.stringify(input), resolvedAgentId, resolvedModel);
2236
+
2237
+ sendJSON(req, res, 200, {
2238
+ id: session.id,
2239
+ threadId: threadId,
2240
+ status: 'pending',
2241
+ started_at: session.started_at,
2242
+ agentId: resolvedAgentId
2243
+ });
2244
+ return;
2245
+ }
2246
+ }
2247
+
2248
+ const threadRunCancelMatch = pathOnly.match(/^\/api\/threads\/([^/]+)\/runs\/([^/]+)\/cancel$/);
2249
+ if (threadRunCancelMatch && req.method === 'POST') {
2250
+ const threadId = threadRunCancelMatch[1];
2251
+ const runId = threadRunCancelMatch[2];
2252
+ const session = queries.getSession(runId);
2253
+
2254
+ if (!session || session.conversationId !== threadId) {
2255
+ sendJSON(req, res, 404, { error: 'Run not found' });
2256
+ return;
2257
+ }
2258
+
2259
+ const entry = activeExecutions.get(threadId);
2260
+
2261
+ if (entry && entry.sessionId === runId) {
2262
+ const { pid } = entry;
2263
+ if (pid) {
2264
+ try {
2265
+ process.kill(-pid, 'SIGKILL');
2266
+ } catch {
2267
+ try {
2268
+ process.kill(pid, 'SIGKILL');
2269
+ } catch (e) {}
2270
+ }
2271
+ }
2272
+ }
2273
+
2274
+ queries.updateSession(runId, { status: 'interrupted', completed_at: Date.now() });
2275
+ queries.setIsStreaming(threadId, false);
2276
+ activeExecutions.delete(threadId);
2277
+
2278
+ broadcastSync({
2279
+ type: 'streaming_complete',
2280
+ sessionId: runId,
2281
+ conversationId: threadId,
2282
+ interrupted: true,
2283
+ timestamp: Date.now()
2284
+ });
2285
+
2286
+ sendJSON(req, res, 204, {});
2287
+ return;
2288
+ }
2289
+
1587
2290
  if (pathOnly === '/api/stt' && req.method === 'POST') {
1588
2291
  try {
1589
2292
  const chunks = [];
@@ -1716,6 +2419,30 @@ const server = http.createServer(async (req, res) => {
1716
2419
  return;
1717
2420
  }
1718
2421
 
2422
+ if (pathOnly === '/api/speech-status' && req.method === 'POST') {
2423
+ const body = await parseBody(req);
2424
+ if (body.forceDownload) {
2425
+ modelDownloadState.complete = false;
2426
+ modelDownloadState.downloading = false;
2427
+ modelDownloadState.error = null;
2428
+ ensureModelsDownloaded().then(ok => {
2429
+ broadcastSync({
2430
+ type: 'model_download_progress',
2431
+ progress: { done: true, complete: ok, error: ok ? null : 'Download failed' }
2432
+ });
2433
+ }).catch(err => {
2434
+ broadcastSync({
2435
+ type: 'model_download_progress',
2436
+ progress: { done: true, error: err.message }
2437
+ });
2438
+ });
2439
+ sendJSON(req, res, 200, { ok: true, message: 'Starting model download' });
2440
+ return;
2441
+ }
2442
+ sendJSON(req, res, 400, { error: 'Unknown request' });
2443
+ return;
2444
+ }
2445
+
1719
2446
  if (pathOnly === '/api/clone' && req.method === 'POST') {
1720
2447
  const body = await parseBody(req);
1721
2448
  const repo = (body.repo || '').trim();
@@ -1773,10 +2500,12 @@ const server = http.createServer(async (req, res) => {
1773
2500
  const remoteUrl = result.trim();
1774
2501
  const statusResult = execSync('git status --porcelain', { encoding: 'utf-8', cwd: STARTUP_CWD });
1775
2502
  const hasChanges = statusResult.trim().length > 0;
2503
+ const unpushedResult = execSync('git rev-list --count --not --remotes 2>/dev/null', { encoding: 'utf-8', cwd: STARTUP_CWD });
2504
+ const hasUnpushed = parseInt(unpushedResult.trim() || '0', 10) > 0;
1776
2505
  const ownsRemote = !remoteUrl.includes('github.com/') || remoteUrl.includes(process.env.GITHUB_USER || '');
1777
- sendJSON(req, res, 200, { ownsRemote, hasChanges, remoteUrl });
2506
+ sendJSON(req, res, 200, { ownsRemote, hasChanges, hasUnpushed, remoteUrl });
1778
2507
  } catch {
1779
- sendJSON(req, res, 200, { ownsRemote: false, hasChanges: false, remoteUrl: '' });
2508
+ sendJSON(req, res, 200, { ownsRemote: false, hasChanges: false, hasUnpushed: false, remoteUrl: '' });
1780
2509
  }
1781
2510
  return;
1782
2511
  }
package/static/index.html CHANGED
@@ -38,6 +38,16 @@
38
38
  --sidebar-width: 300px;
39
39
  --header-height: 52px;
40
40
  --msg-max-width: 100%;
41
+ --block-color-0: #8b5cf6;
42
+ --block-color-1: #ec4899;
43
+ --block-color-2: #14b8a6;
44
+ --block-color-3: #f97316;
45
+ --block-color-4: #06b6d4;
46
+ --block-color-5: #84cc16;
47
+ --block-color-6: #a855f7;
48
+ --block-color-7: #f43f5e;
49
+ --block-color-8: #22c55e;
50
+ --block-color-9: #eab308;
41
51
  }
42
52
 
43
53
  html.dark {
@@ -46,6 +56,16 @@
46
56
  --color-text-primary: #f9fafb;
47
57
  --color-text-secondary: #d1d5db;
48
58
  --color-border: #374151;
59
+ --block-color-0: #a78bfa;
60
+ --block-color-1: #f472b6;
61
+ --block-color-2: #2dd4bf;
62
+ --block-color-3: #fb923c;
63
+ --block-color-4: #22d3ee;
64
+ --block-color-5: #a3e635;
65
+ --block-color-6: #c084fc;
66
+ --block-color-7: #fb7185;
67
+ --block-color-8: #4ade80;
68
+ --block-color-9: #facc15;
49
69
  }
50
70
 
51
71
  html, body {
@@ -878,6 +898,45 @@
878
898
 
879
899
  .send-btn svg { width: 18px; height: 18px; }
880
900
 
901
+ .stop-btn {
902
+ display: none;
903
+ align-items: center;
904
+ justify-content: center;
905
+ width: 40px;
906
+ height: 40px;
907
+ background-color: #dc2626;
908
+ color: white;
909
+ border: none;
910
+ border-radius: 0.5rem;
911
+ cursor: pointer;
912
+ flex-shrink: 0;
913
+ transition: background-color 0.15s;
914
+ }
915
+
916
+ .stop-btn:hover:not(:disabled) { background-color: #b91c1c; }
917
+ .stop-btn:disabled { opacity: 0.4; cursor: not-allowed; }
918
+ .stop-btn.visible { display: flex; }
919
+
920
+ .inject-btn {
921
+ display: none;
922
+ align-items: center;
923
+ justify-content: center;
924
+ width: 40px;
925
+ height: 40px;
926
+ background-color: #f59e0b;
927
+ color: white;
928
+ border: none;
929
+ border-radius: 0.5rem;
930
+ cursor: pointer;
931
+ flex-shrink: 0;
932
+ transition: background-color 0.15s;
933
+ margin-right: 0.25rem;
934
+ }
935
+
936
+ .inject-btn:hover:not(:disabled) { background-color: #d97706; }
937
+ .inject-btn:disabled { opacity: 0.4; cursor: not-allowed; }
938
+ .inject-btn.visible { display: flex; }
939
+
881
940
  /* ===== OVERLAY for mobile sidebar ===== */
882
941
  .sidebar-overlay {
883
942
  display: none;
@@ -1275,7 +1334,6 @@
1275
1334
  .streaming-blocks { contain: content; }
1276
1335
  .sidebar-list { contain: strict; content-visibility: auto; }
1277
1336
  .message { contain: layout style; content-visibility: auto; contain-intrinsic-size: auto 80px; }
1278
- #output-scroll { will-change: transform; }
1279
1337
 
1280
1338
  .voice-block .voice-result-stats {
1281
1339
  font-size: 0.8rem;
@@ -2489,6 +2547,16 @@
2489
2547
  aria-label="Message input"
2490
2548
  rows="1"
2491
2549
  ></textarea>
2550
+ <button class="inject-btn" id="injectBtn" title="Inject instructions into running agent" aria-label="Inject instructions">
2551
+ <svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18">
2552
+ <path d="M20 12l-1.41-1.41L13 16.17V4h-2v12.17l-5.58-5.59L4 12l8 8 8-8z"/>
2553
+ </svg>
2554
+ </button>
2555
+ <button class="stop-btn" id="stopBtn" title="Stop running agent (emergency)" aria-label="Stop agent">
2556
+ <svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18">
2557
+ <path d="M6 6h12v12H6z"/>
2558
+ </svg>
2559
+ </button>
2492
2560
  <button class="send-btn" data-send-button title="Send message (Ctrl+Enter)" aria-label="Send message">
2493
2561
  <svg viewBox="0 0 24 24" fill="currentColor">
2494
2562
  <path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/>
@@ -110,6 +110,9 @@ class AgentGUIClient {
110
110
  await this.loadAgents();
111
111
  await this.loadConversations();
112
112
 
113
+ // Check speech model status
114
+ await this.checkSpeechStatus();
115
+
113
116
  // Enable controls for initial interaction
114
117
  this.enableControls();
115
118
 
@@ -344,6 +347,43 @@ class AgentGUIClient {
344
347
  this.ui.sendButton.addEventListener('click', () => this.startExecution());
345
348
  }
346
349
 
350
+ this.ui.stopButton = document.getElementById('stopBtn');
351
+ this.ui.injectButton = document.getElementById('injectBtn');
352
+
353
+ if (this.ui.stopButton) {
354
+ this.ui.stopButton.addEventListener('click', async () => {
355
+ if (!this.state.currentConversation) return;
356
+ try {
357
+ const resp = await fetch(`${window.__BASE_URL}/api/conversations/${this.state.currentConversation.id}/cancel`, {
358
+ method: 'POST'
359
+ });
360
+ const data = await resp.json();
361
+ console.log('Stop response:', data);
362
+ } catch (err) {
363
+ console.error('Failed to stop:', err);
364
+ }
365
+ });
366
+ }
367
+
368
+ if (this.ui.injectButton) {
369
+ this.ui.injectButton.addEventListener('click', async () => {
370
+ if (!this.state.currentConversation) return;
371
+ const instructions = prompt('Enter instructions to inject into the running agent:');
372
+ if (!instructions) return;
373
+ try {
374
+ const resp = await fetch(`${window.__BASE_URL}/api/conversations/${this.state.currentConversation.id}/inject`, {
375
+ method: 'POST',
376
+ headers: { 'Content-Type': 'application/json' },
377
+ body: JSON.stringify({ content: instructions })
378
+ });
379
+ const data = await resp.json();
380
+ console.log('Inject response:', data);
381
+ } catch (err) {
382
+ console.error('Failed to inject:', err);
383
+ }
384
+ });
385
+ }
386
+
347
387
  if (this.ui.messageInput) {
348
388
  this.ui.messageInput.addEventListener('keydown', (e) => {
349
389
  if (e.key === 'Enter' && e.ctrlKey) {
@@ -468,6 +508,11 @@ class AgentGUIClient {
468
508
  this._serverProcessingEstimate = 0.7 * this._serverProcessingEstimate + 0.3 * serverTime;
469
509
  }
470
510
 
511
+ // Show stop and inject buttons when streaming starts
512
+ if (this.ui.stopButton) this.ui.stopButton.classList.add('visible');
513
+ if (this.ui.injectButton) this.ui.injectButton.classList.add('visible');
514
+ if (this.ui.sendButton) this.ui.sendButton.style.display = 'none';
515
+
471
516
  // If this streaming event is for a different conversation than what we are viewing,
472
517
  // just track the state but do not modify the DOM or start polling
473
518
  if (this.state.currentConversation?.id !== data.conversationId) {
@@ -720,6 +765,11 @@ class AgentGUIClient {
720
765
  console.error('Streaming error:', data);
721
766
  this._clearThinkingCountdown();
722
767
 
768
+ // Hide stop and inject buttons on error
769
+ if (this.ui.stopButton) this.ui.stopButton.classList.remove('visible');
770
+ if (this.ui.injectButton) this.ui.injectButton.classList.remove('visible');
771
+ if (this.ui.sendButton) this.ui.sendButton.style.display = '';
772
+
723
773
  const conversationId = data.conversationId || this.state.currentSession?.conversationId;
724
774
 
725
775
  // If this event is for a conversation we are NOT currently viewing, just track state
@@ -752,6 +802,11 @@ class AgentGUIClient {
752
802
  console.log('Streaming completed:', data);
753
803
  this._clearThinkingCountdown();
754
804
 
805
+ // Hide stop and inject buttons when streaming completes
806
+ if (this.ui.stopButton) this.ui.stopButton.classList.remove('visible');
807
+ if (this.ui.injectButton) this.ui.injectButton.classList.remove('visible');
808
+ if (this.ui.sendButton) this.ui.sendButton.style.display = '';
809
+
755
810
  const conversationId = data.conversationId || this.state.currentSession?.conversationId;
756
811
  if (conversationId) this.invalidateCache(conversationId);
757
812
 
@@ -788,28 +843,21 @@ class AgentGUIClient {
788
843
  this.enableControls();
789
844
  this.emit('streaming:complete', data);
790
845
 
791
- if (data.agentId && this._isACPAgent(data.agentId)) {
792
- this._promptPushIfWeOwnRemote();
793
- }
794
- }
795
-
796
- _isACPAgent(agentId) {
797
- const acpAgents = ['opencode', 'gemini', 'goose', 'openhands', 'augment', 'cline', 'kimi', 'qwen', 'codex', 'mistral', 'kiro', 'fast-agent'];
798
- return acpAgents.includes(agentId);
846
+ this._promptPushIfWeOwnRemote();
799
847
  }
800
848
 
801
849
  async _promptPushIfWeOwnRemote() {
802
850
  try {
803
851
  const result = await fetch(window.__BASE_URL + '/api/git/check-remote-ownership');
804
- const { ownsRemote, hasChanges, remoteUrl } = await result.json();
805
- if (ownsRemote && hasChanges) {
806
- const shouldPush = confirm('Push changes to remote?');
807
- if (shouldPush) {
808
- await fetch(window.__BASE_URL + '/api/git/push', { method: 'POST' });
852
+ const { ownsRemote, hasChanges, hasUnpushed, remoteUrl } = await result.json();
853
+ if (ownsRemote && (hasChanges || hasUnpushed)) {
854
+ const conv = this.state.currentConversation;
855
+ if (conv) {
856
+ this.streamToConversation(conv.id, 'Push the changes to the remote repository.', conv.agentId);
809
857
  }
810
858
  }
811
859
  } catch (e) {
812
- console.warn('Failed to check git remote ownership:', e);
860
+ console.warn('Auto-push check failed:', e);
813
861
  }
814
862
  }
815
863
 
@@ -1788,6 +1836,23 @@ class AgentGUIClient {
1788
1836
  });
1789
1837
  }
1790
1838
 
1839
+ async checkSpeechStatus() {
1840
+ try {
1841
+ const response = await fetch(window.__BASE_URL + '/api/speech-status');
1842
+ const status = await response.json();
1843
+ if (status.modelsComplete) {
1844
+ this._modelDownloadProgress = { done: true, complete: true };
1845
+ this._modelDownloadInProgress = false;
1846
+ } else if (status.modelsDownloading) {
1847
+ this._modelDownloadProgress = status.modelsProgress || { downloading: true };
1848
+ this._modelDownloadInProgress = true;
1849
+ }
1850
+ this._updateVoiceTabState();
1851
+ } catch (error) {
1852
+ console.error('Failed to check speech status:', error);
1853
+ }
1854
+ }
1855
+
1791
1856
  async loadModelsForAgent(agentId) {
1792
1857
  if (!agentId || !this.ui.modelSelector) return;
1793
1858
  const cached = this._modelCache.get(agentId);
@@ -1938,6 +2003,7 @@ class AgentGUIClient {
1938
2003
  this._modelDownloadInProgress = false;
1939
2004
  console.log('[Models] Download complete');
1940
2005
  this._updateConnectionIndicator(this.wsManager?.latency?.quality || 'unknown');
2006
+ this._updateVoiceTabState();
1941
2007
  return;
1942
2008
  }
1943
2009
 
@@ -1947,6 +2013,16 @@ class AgentGUIClient {
1947
2013
  }
1948
2014
  }
1949
2015
 
2016
+ _updateVoiceTabState() {
2017
+ var voiceBtn = document.querySelector('[data-view="voice"]');
2018
+ if (voiceBtn) {
2019
+ var isReady = this._modelDownloadProgress?.done === true ||
2020
+ this._modelDownloadProgress?.complete === true;
2021
+ voiceBtn.disabled = !isReady;
2022
+ voiceBtn.title = isReady ? 'Voice' : 'Downloading voice models...';
2023
+ }
2024
+ }
2025
+
1950
2026
  _toggleConnectionTooltip() {
1951
2027
  let tooltip = document.getElementById('connection-tooltip');
1952
2028
  if (tooltip) { tooltip.remove(); return; }
@@ -188,11 +188,25 @@
188
188
  buttons.forEach(function(btn) {
189
189
  btn.addEventListener('click', function() {
190
190
  var view = btn.dataset.view;
191
+ if (view === 'voice' && !isVoiceReady()) {
192
+ showToast('Downloading voice models... please wait', 'info');
193
+ return;
194
+ }
191
195
  switchView(view);
192
196
  });
193
197
  });
194
198
  }
195
199
 
200
+ function isVoiceReady() {
201
+ if (window.agentGUIClient && window.agentGUIClient._modelDownloadInProgress === false) {
202
+ return window.agentGUIClient._modelDownloadProgress?.done === true ||
203
+ window.agentGUIClient._modelDownloadProgress?.complete === true;
204
+ }
205
+ return false;
206
+ }
207
+
208
+ window.__checkVoiceReady = isVoiceReady;
209
+
196
210
  function switchView(view) {
197
211
  currentView = view;
198
212
  var bar = document.getElementById('viewToggleBar');
@@ -399,9 +399,25 @@ class StreamingRenderer {
399
399
  div.className = 'block-text';
400
400
  if (isHtml) div.classList.add('html-content');
401
401
  div.innerHTML = html;
402
+ const colorIndex = this._getUniqueColorIndex(text);
403
+ div.style.borderLeft = `3px solid var(--block-color-${colorIndex})`;
402
404
  return div;
403
405
  }
404
406
 
407
+ _getUniqueColorIndex(text) {
408
+ if (!this._colorIndexMap) {
409
+ this._colorIndexMap = new Map();
410
+ this._colorCounter = 0;
411
+ }
412
+ let index = this._colorIndexMap.get(text);
413
+ if (index === undefined) {
414
+ index = this._colorCounter % 10;
415
+ this._colorIndexMap.set(text, index);
416
+ this._colorCounter++;
417
+ }
418
+ return index;
419
+ }
420
+
405
421
  containsHtmlTags(text) {
406
422
  const htmlPattern = /<(?:div|table|section|article|ul|ol|dl|nav|header|footer|main|aside|figure|details|summary|h[1-6]|p|blockquote|pre|code|span|strong|em|a|img|br|hr|li|td|tr|th|thead|tbody|tfoot)\b[^>]*>/i;
407
423
  return htmlPattern.test(text);
@@ -296,7 +296,19 @@
296
296
  if (!ttsEnabled) return;
297
297
  var clean = text.replace(/<[^>]*>/g, '').trim();
298
298
  if (!clean) return;
299
- speechQueue.push(clean);
299
+ var parts = [];
300
+ if (typeof agentGUIClient !== 'undefined' && agentGUIClient && typeof agentGUIClient.parseMarkdownCodeBlocks === 'function') {
301
+ parts = agentGUIClient.parseMarkdownCodeBlocks(clean);
302
+ } else {
303
+ parts = [{ type: 'text', content: clean }];
304
+ }
305
+ parts.forEach(function(part) {
306
+ if (part.type === 'code') return;
307
+ var segment = part.content.trim();
308
+ if (segment) {
309
+ speechQueue.push(segment);
310
+ }
311
+ });
300
312
  processQueue();
301
313
  }
302
314