claude-ws 0.3.97 → 0.3.99
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/locales/de.json +374 -12
- package/locales/en.json +374 -12
- package/locales/es.json +398 -11
- package/locales/fr.json +398 -11
- package/locales/ja.json +398 -11
- package/locales/ko.json +398 -11
- package/locales/vi.json +374 -12
- package/locales/zh.json +398 -11
- package/package.json +1 -1
- package/server.ts +283 -6
- package/src/app/[locale]/not-found.tsx +6 -3
- package/src/app/[locale]/page.tsx +14 -4
- package/src/app/api/attempts/[id]/workflow/route.ts +76 -0
- package/src/app/api/questions/answer/route.ts +58 -0
- package/src/app/api/questions/route.ts +68 -0
- package/src/app/api/tasks/[id]/compact/route.ts +62 -0
- package/src/components/access-anywhere/api-access-key-setup-modal.tsx +2 -2
- package/src/components/access-anywhere/tunnel-settings-dialog.tsx +6 -6
- package/src/components/access-anywhere/wizard-step-ctunnel.tsx +8 -8
- package/src/components/agent-factory/dependency-tree.tsx +5 -3
- package/src/components/agent-factory/discovery-dialog.tsx +26 -22
- package/src/components/agent-factory/plugin-detail-dialog.tsx +41 -38
- package/src/components/agent-factory/plugin-form-dialog.tsx +23 -20
- package/src/components/agent-factory/plugin-list.tsx +20 -17
- package/src/components/agent-factory/upload-dialog.tsx +17 -14
- package/src/components/auth/agent-provider-dialog.tsx +67 -65
- package/src/components/auth/api-key-dialog.tsx +14 -11
- package/src/components/auth/auth-error-message.tsx +6 -3
- package/src/components/editor/code-editor-with-inline-edit.tsx +4 -2
- package/src/components/editor/file-diff-resolver-modal.tsx +31 -26
- package/src/components/editor/inline-edit-dialog.tsx +9 -6
- package/src/components/editor/selection-mention-popup.tsx +3 -1
- package/src/components/header/project-selector.tsx +7 -4
- package/src/components/header.tsx +70 -4
- package/src/components/kanban/column.tsx +11 -0
- package/src/components/kanban/task-card.tsx +70 -4
- package/src/components/project-settings/component-selector.tsx +3 -1
- package/src/components/project-settings/plugin-upload-dialog.tsx +7 -5
- package/src/components/project-settings/project-settings-dialog.tsx +5 -3
- package/src/components/questions/questions-panel.tsx +136 -0
- package/src/components/settings/folder-browser-dialog.tsx +29 -25
- package/src/components/settings/settings-page.tsx +64 -18
- package/src/components/settings/setup-dialog.tsx +26 -23
- package/src/components/setup/unified-setup-wizard.tsx +12 -9
- package/src/components/sidebar/file-browser/file-create-buttons.tsx +7 -3
- package/src/components/sidebar/file-browser/file-tab-content.tsx +19 -15
- package/src/components/sidebar/file-browser/file-tabs-panel.tsx +7 -4
- package/src/components/sidebar/file-browser/file-tree.tsx +3 -1
- package/src/components/sidebar/git-changes/branch-checkout-modal.tsx +6 -4
- package/src/components/sidebar/git-changes/commit-details-modal.tsx +5 -3
- package/src/components/sidebar/git-changes/diff-tabs-panel.tsx +3 -1
- package/src/components/sidebar/git-changes/git-file-item.tsx +8 -6
- package/src/components/sidebar/git-changes/git-graph.tsx +8 -5
- package/src/components/sidebar/git-changes/git-panel.tsx +28 -27
- package/src/components/sidebar/git-changes/git-section.tsx +5 -3
- package/src/components/sidebar/shells/shell-panel.tsx +3 -1
- package/src/components/task/attachment-bar.tsx +4 -1
- package/src/components/task/attempt-item.tsx +7 -5
- package/src/components/task/conversation-view.tsx +21 -13
- package/src/components/task/floating-chat-window.tsx +14 -5
- package/src/components/task/interactive-command/checkpoint-list.tsx +5 -3
- package/src/components/task/interactive-command/confirm-dialog.tsx +9 -4
- package/src/components/task/interactive-command/interactive-command-overlay.tsx +23 -9
- package/src/components/task/interactive-command/question-prompt.tsx +12 -8
- package/src/components/task/pending-question-indicator.tsx +5 -3
- package/src/components/task/prompt-input.tsx +1 -1
- package/src/components/task/shell-log-view.tsx +3 -1
- package/src/components/task/status-line.tsx +84 -23
- package/src/components/task/task-detail-panel.tsx +27 -27
- package/src/components/task/task-shell-indicator.tsx +10 -6
- package/src/components/terminal/terminal-context-menu.tsx +6 -4
- package/src/components/terminal/terminal-instance.tsx +11 -3
- package/src/components/terminal/terminal-panel.tsx +6 -3
- package/src/components/terminal/terminal-shortcut-bar.tsx +3 -1
- package/src/components/terminal/terminal-tab-bar.tsx +5 -3
- package/src/components/workflow/workflow-panel.tsx +181 -0
- package/src/hooks/use-attempt-stream.ts +96 -3
- package/src/lib/agent-manager.ts +89 -3
- package/src/lib/db/index.ts +18 -0
- package/src/lib/db/schema.ts +29 -0
- package/src/lib/process-manager.ts +28 -7
- package/src/lib/session-manager.ts +60 -0
- package/src/lib/usage-tracker.ts +19 -19
- package/src/lib/workflow-tracker.ts +118 -20
- package/src/stores/questions-store.ts +76 -0
- package/src/stores/workflow-store.ts +71 -0
package/server.ts
CHANGED
|
@@ -113,6 +113,9 @@ app.prepare().then(async () => {
|
|
|
113
113
|
pingTimeout: 10000,
|
|
114
114
|
});
|
|
115
115
|
|
|
116
|
+
// Disconnect cleanup timers - keyed by attemptId
|
|
117
|
+
const disconnectTimers = new Map<string, NodeJS.Timeout>();
|
|
118
|
+
|
|
116
119
|
// Socket.io connection handler
|
|
117
120
|
io.on('connection', (socket) => {
|
|
118
121
|
log.info(`Client connected: ${socket.id}`);
|
|
@@ -408,6 +411,14 @@ app.prepare().then(async () => {
|
|
|
408
411
|
socket.on('attempt:subscribe', (data: { attemptId: string }) => {
|
|
409
412
|
log.info(`[Server] Socket ${socket.id} subscribing to attempt:${data.attemptId}`);
|
|
410
413
|
socket.join(`attempt:${data.attemptId}`);
|
|
414
|
+
|
|
415
|
+
// Clear disconnect timer if client reconnected
|
|
416
|
+
const timer = disconnectTimers.get(data.attemptId);
|
|
417
|
+
if (timer) {
|
|
418
|
+
clearTimeout(timer);
|
|
419
|
+
disconnectTimers.delete(data.attemptId);
|
|
420
|
+
log.info({ attemptId: data.attemptId }, '[Server] Cleared disconnect timer on reconnect');
|
|
421
|
+
}
|
|
411
422
|
});
|
|
412
423
|
|
|
413
424
|
// Unsubscribe from attempt logs
|
|
@@ -527,6 +538,48 @@ app.prepare().then(async () => {
|
|
|
527
538
|
}
|
|
528
539
|
);
|
|
529
540
|
|
|
541
|
+
// Handle manual compact request
|
|
542
|
+
socket.on('attempt:compact', async (data: { taskId: string }) => {
|
|
543
|
+
const { taskId: compactTaskId } = data;
|
|
544
|
+
log.info({ taskId: compactTaskId }, '[Server] Manual compact requested');
|
|
545
|
+
|
|
546
|
+
try {
|
|
547
|
+
const task = await db.query.tasks.findFirst({
|
|
548
|
+
where: eq(schema.tasks.id, compactTaskId),
|
|
549
|
+
});
|
|
550
|
+
if (!task) { socket.emit('error', { message: 'Task not found' }); return; }
|
|
551
|
+
|
|
552
|
+
const project = await db.query.projects.findFirst({
|
|
553
|
+
where: eq(schema.projects.id, task.projectId),
|
|
554
|
+
});
|
|
555
|
+
if (!project) { socket.emit('error', { message: 'Project not found' }); return; }
|
|
556
|
+
|
|
557
|
+
const conversationSummary = await sessionManager.getConversationSummary(compactTaskId);
|
|
558
|
+
|
|
559
|
+
const compactAttemptId = nanoid();
|
|
560
|
+
await db.insert(schema.attempts).values({
|
|
561
|
+
id: compactAttemptId,
|
|
562
|
+
taskId: compactTaskId,
|
|
563
|
+
prompt: 'Manual compact: summarize conversation context',
|
|
564
|
+
displayPrompt: 'Compacting conversation...',
|
|
565
|
+
status: 'running',
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
socket.join(`attempt:${compactAttemptId}`);
|
|
569
|
+
socket.emit('attempt:started', { attemptId: compactAttemptId, taskId: compactTaskId });
|
|
570
|
+
io.to(`attempt:${compactAttemptId}`).emit('context:compacting', { attemptId: compactAttemptId, taskId: compactTaskId });
|
|
571
|
+
|
|
572
|
+
agentManager.compact({
|
|
573
|
+
attemptId: compactAttemptId,
|
|
574
|
+
projectPath: project.path,
|
|
575
|
+
conversationSummary,
|
|
576
|
+
});
|
|
577
|
+
} catch (error) {
|
|
578
|
+
log.error({ error }, '[Server] Manual compact failed');
|
|
579
|
+
socket.emit('error', { message: 'Compact failed: ' + (error instanceof Error ? error.message : 'Unknown error') });
|
|
580
|
+
}
|
|
581
|
+
});
|
|
582
|
+
|
|
530
583
|
// ========================================
|
|
531
584
|
// Inline Edit Socket Handlers
|
|
532
585
|
// ========================================
|
|
@@ -688,6 +741,49 @@ app.prepare().then(async () => {
|
|
|
688
741
|
|
|
689
742
|
socket.on('disconnect', () => {
|
|
690
743
|
log.info(`Client disconnected: ${socket.id}`);
|
|
744
|
+
|
|
745
|
+
// Find all attempt rooms this socket was in
|
|
746
|
+
// Socket.io automatically removes the socket from rooms on disconnect
|
|
747
|
+
// but we can check which rooms had this socket before disconnect
|
|
748
|
+
const socketRooms = Array.from(socket.rooms || []);
|
|
749
|
+
const attemptRooms = socketRooms
|
|
750
|
+
.filter(room => room.startsWith('attempt:'))
|
|
751
|
+
.map(room => room.replace('attempt:', ''));
|
|
752
|
+
|
|
753
|
+
for (const attemptId of attemptRooms) {
|
|
754
|
+
// Start grace timer for this attempt
|
|
755
|
+
if (disconnectTimers.has(attemptId)) continue; // Already has a timer
|
|
756
|
+
|
|
757
|
+
const timer = setTimeout(async () => {
|
|
758
|
+
disconnectTimers.delete(attemptId);
|
|
759
|
+
|
|
760
|
+
// Check if attempt room still has 0 clients
|
|
761
|
+
const room = io.sockets.adapter.rooms.get(`attempt:${attemptId}`);
|
|
762
|
+
if (room && room.size > 0) {
|
|
763
|
+
log.info({ attemptId, clients: room.size }, '[Server] Attempt room still has clients, skipping cleanup');
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// Check if attempt is still running
|
|
768
|
+
if (!agentManager.isRunning(attemptId)) return;
|
|
769
|
+
|
|
770
|
+
log.info({ attemptId }, '[Server] No clients for 30s, cancelling orphaned attempt');
|
|
771
|
+
|
|
772
|
+
// Cancel the agent
|
|
773
|
+
agentManager.cancel(attemptId);
|
|
774
|
+
|
|
775
|
+
// Mark subagents as orphaned in DB
|
|
776
|
+
try {
|
|
777
|
+
await db.update(schema.subagents)
|
|
778
|
+
.set({ status: 'orphaned', completedAt: Date.now() })
|
|
779
|
+
.where(eq(schema.subagents.attemptId, attemptId));
|
|
780
|
+
} catch (err) {
|
|
781
|
+
log.error({ err, attemptId }, '[Server] Failed to mark subagents as orphaned on disconnect');
|
|
782
|
+
}
|
|
783
|
+
}, 30000);
|
|
784
|
+
|
|
785
|
+
disconnectTimers.set(attemptId, timer);
|
|
786
|
+
}
|
|
691
787
|
});
|
|
692
788
|
});
|
|
693
789
|
|
|
@@ -857,21 +953,53 @@ app.prepare().then(async () => {
|
|
|
857
953
|
});
|
|
858
954
|
|
|
859
955
|
// Handle AskUserQuestion detection from AgentManager
|
|
860
|
-
agentManager.on('question', ({ attemptId, toolUseId, questions }) => {
|
|
956
|
+
agentManager.on('question', async ({ attemptId, toolUseId, questions }) => {
|
|
861
957
|
log.info({
|
|
862
958
|
attemptId,
|
|
863
959
|
toolUseId,
|
|
864
960
|
questionCount: questions?.length,
|
|
865
961
|
questions: questions?.map((q: any) => ({ header: q.header, question: q.question?.substring(0, 50) }))
|
|
866
962
|
}, '[Server] AskUserQuestion detected');
|
|
963
|
+
|
|
964
|
+
// Emit to attempt room (existing behavior)
|
|
867
965
|
io.to(`attempt:${attemptId}`).emit('question:ask', {
|
|
868
966
|
attemptId,
|
|
869
967
|
toolUseId,
|
|
870
968
|
questions,
|
|
871
969
|
});
|
|
970
|
+
|
|
971
|
+
// Emit global question:new event for the questions panel
|
|
972
|
+
// Look up taskId from the attempt
|
|
973
|
+
try {
|
|
974
|
+
const attempt = await db.query.attempts.findFirst({
|
|
975
|
+
where: eq(schema.attempts.id, attemptId),
|
|
976
|
+
});
|
|
977
|
+
if (attempt) {
|
|
978
|
+
const task = await db.query.tasks.findFirst({
|
|
979
|
+
where: eq(schema.tasks.id, attempt.taskId),
|
|
980
|
+
});
|
|
981
|
+
io.emit('question:new', {
|
|
982
|
+
attemptId,
|
|
983
|
+
taskId: attempt.taskId,
|
|
984
|
+
taskTitle: task?.title || '',
|
|
985
|
+
projectId: task?.projectId || '',
|
|
986
|
+
toolUseId,
|
|
987
|
+
questions,
|
|
988
|
+
timestamp: Date.now(),
|
|
989
|
+
});
|
|
990
|
+
}
|
|
991
|
+
} catch (err) {
|
|
992
|
+
log.error({ err }, '[Server] Failed to emit global question:new');
|
|
993
|
+
}
|
|
994
|
+
|
|
872
995
|
log.info(`[Server] Emitted question:ask to attempt:${attemptId}`);
|
|
873
996
|
});
|
|
874
997
|
|
|
998
|
+
// Handle question resolved from AgentManager (answered or cancelled, including via REST API)
|
|
999
|
+
agentManager.on('questionResolved', ({ attemptId }) => {
|
|
1000
|
+
io.emit('question:resolved', { attemptId });
|
|
1001
|
+
});
|
|
1002
|
+
|
|
875
1003
|
// Handle background shell detection from AgentManager (Bash with run_in_background=true)
|
|
876
1004
|
// NOTE: SDK spawns process but it dies when conversation ends.
|
|
877
1005
|
// We spawn our own detached shell that survives.
|
|
@@ -1038,6 +1166,31 @@ app.prepare().then(async () => {
|
|
|
1038
1166
|
}
|
|
1039
1167
|
});
|
|
1040
1168
|
|
|
1169
|
+
// Handle "prompt too long" error - trigger auto-compact if enabled
|
|
1170
|
+
agentManager.on('promptTooLong', async ({ attemptId }) => {
|
|
1171
|
+
log.warn({ attemptId }, '[Server] Prompt too long detected');
|
|
1172
|
+
|
|
1173
|
+
try {
|
|
1174
|
+
const autoCompactSetting = await db
|
|
1175
|
+
.select()
|
|
1176
|
+
.from(schema.appSettings)
|
|
1177
|
+
.where(eq(schema.appSettings.key, 'auto_compact_enabled'))
|
|
1178
|
+
.limit(1);
|
|
1179
|
+
|
|
1180
|
+
const autoCompactEnabled = autoCompactSetting.length > 0 && autoCompactSetting[0].value === 'true';
|
|
1181
|
+
|
|
1182
|
+
io.to(`attempt:${attemptId}`).emit('context:prompt-too-long', {
|
|
1183
|
+
attemptId,
|
|
1184
|
+
autoCompactEnabled,
|
|
1185
|
+
message: autoCompactEnabled
|
|
1186
|
+
? 'Context limit exceeded. Auto-compacting...'
|
|
1187
|
+
: 'Context limit exceeded. Use /compact to reduce context size, or start a new conversation.',
|
|
1188
|
+
});
|
|
1189
|
+
} catch (error) {
|
|
1190
|
+
log.error({ error }, '[Server] Failed to handle prompt-too-long');
|
|
1191
|
+
}
|
|
1192
|
+
});
|
|
1193
|
+
|
|
1041
1194
|
// Register exit event handler
|
|
1042
1195
|
agentManager.on('exit', async ({ attemptId, code }) => {
|
|
1043
1196
|
// Get attempt to retrieve taskId and current status
|
|
@@ -1164,6 +1317,73 @@ app.prepare().then(async () => {
|
|
|
1164
1317
|
if (attempt?.taskId) {
|
|
1165
1318
|
io.emit('task:finished', { taskId: attempt.taskId, status });
|
|
1166
1319
|
}
|
|
1320
|
+
|
|
1321
|
+
// Auto-compact check: if context exceeded threshold and auto-compact is enabled
|
|
1322
|
+
if (status === 'completed' && usageStats?.contextHealth?.shouldCompact && attempt?.taskId) {
|
|
1323
|
+
try {
|
|
1324
|
+
const autoCompactSetting = await db
|
|
1325
|
+
.select()
|
|
1326
|
+
.from(schema.appSettings)
|
|
1327
|
+
.where(eq(schema.appSettings.key, 'auto_compact_enabled'))
|
|
1328
|
+
.limit(1);
|
|
1329
|
+
|
|
1330
|
+
const autoCompactEnabled = autoCompactSetting.length > 0 && autoCompactSetting[0].value === 'true';
|
|
1331
|
+
|
|
1332
|
+
if (autoCompactEnabled) {
|
|
1333
|
+
const project = await db.query.projects.findFirst({
|
|
1334
|
+
where: eq(schema.projects.id, (await db.query.tasks.findFirst({ where: eq(schema.tasks.id, attempt.taskId) }))!.projectId),
|
|
1335
|
+
});
|
|
1336
|
+
|
|
1337
|
+
if (project) {
|
|
1338
|
+
const conversationSummary = await sessionManager.getConversationSummary(attempt.taskId);
|
|
1339
|
+
|
|
1340
|
+
const compactAttemptId = nanoid();
|
|
1341
|
+
await db.insert(schema.attempts).values({
|
|
1342
|
+
id: compactAttemptId,
|
|
1343
|
+
taskId: attempt.taskId,
|
|
1344
|
+
prompt: 'Auto-compact: summarize conversation context',
|
|
1345
|
+
displayPrompt: 'Auto-compacting conversation...',
|
|
1346
|
+
status: 'running',
|
|
1347
|
+
});
|
|
1348
|
+
|
|
1349
|
+
log.info({ attemptId: compactAttemptId, taskId: attempt.taskId }, '[Server] Auto-compacting conversation');
|
|
1350
|
+
io.to(`attempt:${attemptId}`).emit('context:compacting', { attemptId: compactAttemptId, taskId: attempt.taskId });
|
|
1351
|
+
|
|
1352
|
+
agentManager.compact({
|
|
1353
|
+
attemptId: compactAttemptId,
|
|
1354
|
+
projectPath: project.path,
|
|
1355
|
+
conversationSummary,
|
|
1356
|
+
});
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
} catch (compactError) {
|
|
1360
|
+
log.error({ compactError }, '[Server] Auto-compact failed');
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
// Mark any remaining in-progress subagents as orphaned in DB
|
|
1365
|
+
const orphanedNodes = workflowTracker.markOrphaned(attemptId);
|
|
1366
|
+
if (orphanedNodes.length > 0) {
|
|
1367
|
+
log.info({ attemptId, count: orphanedNodes.length }, '[Server] Marking orphaned subagents');
|
|
1368
|
+
for (const node of orphanedNodes) {
|
|
1369
|
+
try {
|
|
1370
|
+
await db.update(schema.subagents)
|
|
1371
|
+
.set({
|
|
1372
|
+
status: 'orphaned',
|
|
1373
|
+
completedAt: node.completedAt || Date.now(),
|
|
1374
|
+
durationMs: node.durationMs || null,
|
|
1375
|
+
})
|
|
1376
|
+
.where(eq(schema.subagents.id, node.id));
|
|
1377
|
+
} catch (err) {
|
|
1378
|
+
log.error({ err, nodeId: node.id }, '[Server] Failed to mark subagent as orphaned');
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
// Clean up in-memory tracking data for this attempt to prevent unbounded growth
|
|
1384
|
+
usageTracker.clearSession(attemptId);
|
|
1385
|
+
workflowTracker.clearWorkflow(attemptId);
|
|
1386
|
+
gitStatsCache.clear(attemptId);
|
|
1167
1387
|
});
|
|
1168
1388
|
|
|
1169
1389
|
// Forward tracking module events to Socket.io clients
|
|
@@ -1177,14 +1397,71 @@ app.prepare().then(async () => {
|
|
|
1177
1397
|
});
|
|
1178
1398
|
|
|
1179
1399
|
// Workflow tracking (subagent execution chain)
|
|
1180
|
-
workflowTracker.on('workflow-update', ({ attemptId
|
|
1181
|
-
const
|
|
1182
|
-
if (
|
|
1183
|
-
log.info({ attemptId, chain: summary.chain }, '[Server] Emitting status:workflow');
|
|
1400
|
+
workflowTracker.on('workflow-update', ({ attemptId }) => {
|
|
1401
|
+
const expanded = workflowTracker.getExpandedWorkflow(attemptId);
|
|
1402
|
+
if (expanded) {
|
|
1403
|
+
log.info({ attemptId, chain: expanded.summary.chain }, '[Server] Emitting status:workflow');
|
|
1184
1404
|
io.to(`attempt:${attemptId}`).emit('status:workflow', {
|
|
1185
1405
|
attemptId,
|
|
1186
|
-
|
|
1406
|
+
nodes: expanded.nodes,
|
|
1407
|
+
messages: expanded.messages,
|
|
1408
|
+
summary: expanded.summary,
|
|
1187
1409
|
});
|
|
1410
|
+
|
|
1411
|
+
// Also emit global workflow:update for cross-task awareness
|
|
1412
|
+
// Look up taskId and title for the attempt
|
|
1413
|
+
db.query.attempts.findFirst({
|
|
1414
|
+
where: eq(schema.attempts.id, attemptId),
|
|
1415
|
+
}).then(attempt => {
|
|
1416
|
+
if (attempt) {
|
|
1417
|
+
db.query.tasks.findFirst({
|
|
1418
|
+
where: eq(schema.tasks.id, attempt.taskId),
|
|
1419
|
+
}).then(task => {
|
|
1420
|
+
io.emit('workflow:update', {
|
|
1421
|
+
attemptId,
|
|
1422
|
+
taskId: attempt.taskId,
|
|
1423
|
+
taskTitle: task?.title || 'Unknown',
|
|
1424
|
+
summary: expanded.summary,
|
|
1425
|
+
});
|
|
1426
|
+
}).catch(() => {});
|
|
1427
|
+
}
|
|
1428
|
+
}).catch(() => {});
|
|
1429
|
+
}
|
|
1430
|
+
});
|
|
1431
|
+
|
|
1432
|
+
// Persist subagent start to DB
|
|
1433
|
+
workflowTracker.on('subagent-start', async ({ attemptId, node }) => {
|
|
1434
|
+
try {
|
|
1435
|
+
await db.insert(schema.subagents).values({
|
|
1436
|
+
id: node.id,
|
|
1437
|
+
attemptId,
|
|
1438
|
+
type: node.type,
|
|
1439
|
+
name: node.name || null,
|
|
1440
|
+
parentId: node.parentId,
|
|
1441
|
+
teamName: node.teamName || null,
|
|
1442
|
+
status: 'in_progress',
|
|
1443
|
+
depth: node.depth,
|
|
1444
|
+
startedAt: node.startedAt || Date.now(),
|
|
1445
|
+
});
|
|
1446
|
+
} catch (err) {
|
|
1447
|
+
log.error({ err, attemptId, nodeId: node.id }, '[Server] Failed to persist subagent start');
|
|
1448
|
+
}
|
|
1449
|
+
});
|
|
1450
|
+
|
|
1451
|
+
// Persist subagent end to DB
|
|
1452
|
+
workflowTracker.on('subagent-end', async ({ attemptId, node }) => {
|
|
1453
|
+
try {
|
|
1454
|
+
const dbStatus = node.status as 'in_progress' | 'completed' | 'failed' | 'orphaned';
|
|
1455
|
+
await db.update(schema.subagents)
|
|
1456
|
+
.set({
|
|
1457
|
+
status: dbStatus,
|
|
1458
|
+
completedAt: node.completedAt || Date.now(),
|
|
1459
|
+
durationMs: node.durationMs || null,
|
|
1460
|
+
error: node.error || null,
|
|
1461
|
+
})
|
|
1462
|
+
.where(eq(schema.subagents.id, node.id));
|
|
1463
|
+
} catch (err) {
|
|
1464
|
+
log.error({ err, attemptId, nodeId: node.id }, '[Server] Failed to persist subagent end');
|
|
1188
1465
|
}
|
|
1189
1466
|
});
|
|
1190
1467
|
|
|
@@ -3,13 +3,16 @@
|
|
|
3
3
|
// Force dynamic rendering to avoid Next.js 16 Turbopack build bug
|
|
4
4
|
export const dynamic = 'force-dynamic';
|
|
5
5
|
|
|
6
|
+
import { useTranslations } from 'next-intl';
|
|
7
|
+
|
|
6
8
|
export default function NotFound() {
|
|
9
|
+
const t = useTranslations('notFound');
|
|
7
10
|
return (
|
|
8
11
|
<div style={{ padding: '2rem', fontFamily: 'system-ui', textAlign: 'center' }}>
|
|
9
|
-
<h1>
|
|
10
|
-
<p>
|
|
12
|
+
<h1>{t('title')}</h1>
|
|
13
|
+
<p>{t('description')}</p>
|
|
11
14
|
<a href="/" style={{ color: '#0070f3', textDecoration: 'underline' }}>
|
|
12
|
-
|
|
15
|
+
{t('goHome')}
|
|
13
16
|
</a>
|
|
14
17
|
</div>
|
|
15
18
|
);
|
|
@@ -11,6 +11,8 @@ import { SettingsPage } from '@/components/settings/settings-page';
|
|
|
11
11
|
import { SetupDialog } from '@/components/settings/setup-dialog';
|
|
12
12
|
import { SidebarPanel, FileTabsPanel, DiffTabsPanel } from '@/components/sidebar';
|
|
13
13
|
import { RightSidebar } from '@/components/right-sidebar';
|
|
14
|
+
import { QuestionsPanel } from '@/components/questions/questions-panel';
|
|
15
|
+
import { WorkflowPanel } from '@/components/workflow/workflow-panel';
|
|
14
16
|
import { PluginList } from '@/components/agent-factory/plugin-list';
|
|
15
17
|
import { AccessAnywhereWizard } from '@/components/access-anywhere';
|
|
16
18
|
import { TerminalPanel } from '@/components/terminal/terminal-panel';
|
|
@@ -24,8 +26,10 @@ import { useAgentFactoryUIStore } from '@/stores/agent-factory-ui-store';
|
|
|
24
26
|
import { useSettingsUIStore } from '@/stores/settings-ui-store';
|
|
25
27
|
import { useIsMobileViewport } from '@/hooks/use-mobile-viewport';
|
|
26
28
|
import { useTerminalStore } from '@/stores/terminal-store';
|
|
29
|
+
import { useTranslations } from 'next-intl';
|
|
27
30
|
|
|
28
31
|
function KanbanApp() {
|
|
32
|
+
const tCommon = useTranslations('common');
|
|
29
33
|
const [createTaskOpen, setCreateTaskOpen] = useState(false);
|
|
30
34
|
const [setupOpen, setSetupOpen] = useState(false);
|
|
31
35
|
const [searchQuery, setSearchQuery] = useState('');
|
|
@@ -134,7 +138,7 @@ function KanbanApp() {
|
|
|
134
138
|
const tab = openTabs.find(t => t.id === tabId);
|
|
135
139
|
if (tab?.isDirty) {
|
|
136
140
|
const fileName = tab.filePath.split('/').pop() || tab.filePath;
|
|
137
|
-
if (!confirm(
|
|
141
|
+
if (!confirm(tCommon('unsavedChangesConfirm', { fileName }))) {
|
|
138
142
|
return;
|
|
139
143
|
}
|
|
140
144
|
}
|
|
@@ -204,7 +208,7 @@ function KanbanApp() {
|
|
|
204
208
|
<div className="flex h-screen items-center justify-center">
|
|
205
209
|
<div className="flex items-center gap-3 text-muted-foreground">
|
|
206
210
|
<img src="/logo.svg" alt="Logo" className="h-8 w-8 animate-spin" />
|
|
207
|
-
<span>
|
|
211
|
+
<span>{tCommon('loadingApp')}</span>
|
|
208
212
|
</div>
|
|
209
213
|
</div>
|
|
210
214
|
);
|
|
@@ -236,12 +240,12 @@ function KanbanApp() {
|
|
|
236
240
|
) : (
|
|
237
241
|
<div className="flex h-full items-center justify-center">
|
|
238
242
|
<div className="text-center">
|
|
239
|
-
<p className="text-muted-foreground mb-4">
|
|
243
|
+
<p className="text-muted-foreground mb-4">{tCommon('noProjectsConfigured')}</p>
|
|
240
244
|
<button
|
|
241
245
|
onClick={() => setSetupOpen(true)}
|
|
242
246
|
className="text-primary underline hover:no-underline"
|
|
243
247
|
>
|
|
244
|
-
|
|
248
|
+
{tCommon('setUpProject')}
|
|
245
249
|
</button>
|
|
246
250
|
</div>
|
|
247
251
|
</div>
|
|
@@ -283,6 +287,12 @@ function KanbanApp() {
|
|
|
283
287
|
onCreateTask={() => setCreateTaskOpen(true)}
|
|
284
288
|
/>
|
|
285
289
|
|
|
290
|
+
{/* Questions Panel - pending questions sidebar */}
|
|
291
|
+
<QuestionsPanel />
|
|
292
|
+
|
|
293
|
+
{/* Workflow Panel - agent workflow sidebar */}
|
|
294
|
+
<WorkflowPanel />
|
|
295
|
+
|
|
286
296
|
{/* Access Anywhere Wizard */}
|
|
287
297
|
<AccessAnywhereWizard />
|
|
288
298
|
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import { db, schema } from '@/lib/db';
|
|
3
|
+
import { eq } from 'drizzle-orm';
|
|
4
|
+
import { workflowTracker } from '@/lib/workflow-tracker';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* GET /api/attempts/[id]/workflow
|
|
8
|
+
*
|
|
9
|
+
* Returns the workflow tree for an attempt.
|
|
10
|
+
* - Running attempt: returns from workflowTracker in-memory state
|
|
11
|
+
* - Completed attempt: returns from DB subagents table
|
|
12
|
+
*/
|
|
13
|
+
export async function GET(
|
|
14
|
+
_request: NextRequest,
|
|
15
|
+
{ params }: { params: Promise<{ id: string }> }
|
|
16
|
+
) {
|
|
17
|
+
const { id: attemptId } = await params;
|
|
18
|
+
|
|
19
|
+
// Try in-memory state first (for running attempts)
|
|
20
|
+
const expanded = workflowTracker.getExpandedWorkflow(attemptId);
|
|
21
|
+
if (expanded) {
|
|
22
|
+
return NextResponse.json({
|
|
23
|
+
source: 'live',
|
|
24
|
+
nodes: expanded.nodes,
|
|
25
|
+
messages: expanded.messages,
|
|
26
|
+
summary: expanded.summary,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Fall back to DB (for completed attempts)
|
|
31
|
+
const subagents = await db.query.subagents.findMany({
|
|
32
|
+
where: eq(schema.subagents.attemptId, attemptId),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
if (subagents.length === 0) {
|
|
36
|
+
return NextResponse.json({
|
|
37
|
+
source: 'db',
|
|
38
|
+
nodes: [],
|
|
39
|
+
messages: [],
|
|
40
|
+
summary: { chain: [], completedCount: 0, activeCount: 0, totalCount: 0 },
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Build summary from DB records
|
|
45
|
+
const rootNodes = subagents.filter(s => !s.parentId);
|
|
46
|
+
const chain = rootNodes.map(s => s.name || s.type);
|
|
47
|
+
const completedCount = subagents.filter(s => s.status === 'completed').length;
|
|
48
|
+
const activeCount = subagents.filter(s => s.status === 'in_progress').length;
|
|
49
|
+
|
|
50
|
+
// Map DB records to SubagentNode format
|
|
51
|
+
const nodes = subagents
|
|
52
|
+
.sort((a, b) => {
|
|
53
|
+
if (a.depth !== b.depth) return a.depth - b.depth;
|
|
54
|
+
return (a.startedAt || 0) - (b.startedAt || 0);
|
|
55
|
+
})
|
|
56
|
+
.map(s => ({
|
|
57
|
+
id: s.id,
|
|
58
|
+
type: s.type,
|
|
59
|
+
name: s.name,
|
|
60
|
+
status: s.status,
|
|
61
|
+
parentId: s.parentId,
|
|
62
|
+
depth: s.depth,
|
|
63
|
+
teamName: s.teamName,
|
|
64
|
+
startedAt: s.startedAt,
|
|
65
|
+
completedAt: s.completedAt,
|
|
66
|
+
durationMs: s.durationMs,
|
|
67
|
+
error: s.error,
|
|
68
|
+
}));
|
|
69
|
+
|
|
70
|
+
return NextResponse.json({
|
|
71
|
+
source: 'db',
|
|
72
|
+
nodes,
|
|
73
|
+
messages: [], // Messages are not persisted to DB
|
|
74
|
+
summary: { chain, completedCount, activeCount, totalCount: subagents.length },
|
|
75
|
+
});
|
|
76
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import { agentManager } from '@/lib/agent-manager';
|
|
3
|
+
import { db, schema } from '@/lib/db';
|
|
4
|
+
import { eq } from 'drizzle-orm';
|
|
5
|
+
|
|
6
|
+
// POST /api/questions/answer - Answer a pending question via REST API
|
|
7
|
+
// Used by the global questions panel (which doesn't have a socket connection to the attempt room)
|
|
8
|
+
export async function POST(request: NextRequest) {
|
|
9
|
+
try {
|
|
10
|
+
const { attemptId, toolUseId, questions, answers } = await request.json() as {
|
|
11
|
+
attemptId: string;
|
|
12
|
+
toolUseId: string;
|
|
13
|
+
questions: unknown[];
|
|
14
|
+
answers: Record<string, string>;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
if (!agentManager.hasPendingQuestion(attemptId)) {
|
|
18
|
+
return NextResponse.json(
|
|
19
|
+
{ error: 'No pending question for this attempt' },
|
|
20
|
+
{ status: 404 }
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const success = agentManager.answerQuestion(attemptId, toolUseId, questions, answers);
|
|
25
|
+
|
|
26
|
+
if (!success) {
|
|
27
|
+
return NextResponse.json(
|
|
28
|
+
{ error: 'Failed to answer question' },
|
|
29
|
+
{ status: 400 }
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Save answer to database for persistence
|
|
34
|
+
const answerText = Object.entries(answers)
|
|
35
|
+
.map(([question, answer]) => `${question}: **${answer}**`)
|
|
36
|
+
.join('\n');
|
|
37
|
+
|
|
38
|
+
await db.insert(schema.attemptLogs).values({
|
|
39
|
+
attemptId,
|
|
40
|
+
type: 'json',
|
|
41
|
+
content: JSON.stringify({
|
|
42
|
+
type: 'user_answer',
|
|
43
|
+
questions,
|
|
44
|
+
answers,
|
|
45
|
+
displayText: `✓ You answered:\n${answerText}`
|
|
46
|
+
}),
|
|
47
|
+
createdAt: Date.now(),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
return NextResponse.json({ success: true });
|
|
51
|
+
} catch (error) {
|
|
52
|
+
console.error('Error answering question:', error);
|
|
53
|
+
return NextResponse.json(
|
|
54
|
+
{ error: 'Failed to answer question' },
|
|
55
|
+
{ status: 500 }
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import { agentManager } from '@/lib/agent-manager';
|
|
3
|
+
import { db, schema } from '@/lib/db';
|
|
4
|
+
import { eq, inArray } from 'drizzle-orm';
|
|
5
|
+
|
|
6
|
+
// GET /api/questions - Get all pending questions across running attempts
|
|
7
|
+
// Optional query param: ?projectIds=id1,id2 to filter by project
|
|
8
|
+
export async function GET(request: NextRequest) {
|
|
9
|
+
try {
|
|
10
|
+
const allPending = agentManager.getAllPendingQuestions();
|
|
11
|
+
|
|
12
|
+
if (allPending.length === 0) {
|
|
13
|
+
return NextResponse.json({ questions: [] });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Look up attemptId → taskId → projectId from DB
|
|
17
|
+
const attemptIds = allPending.map((p) => p.attemptId);
|
|
18
|
+
const attempts = await db.query.attempts.findMany({
|
|
19
|
+
where: inArray(schema.attempts.id, attemptIds),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const attemptMap = new Map(attempts.map((a) => [a.id, a]));
|
|
23
|
+
|
|
24
|
+
// Get task info for all relevant tasks
|
|
25
|
+
const taskIds = [...new Set(attempts.map((a) => a.taskId))];
|
|
26
|
+
const tasks = taskIds.length > 0
|
|
27
|
+
? await db.query.tasks.findMany({
|
|
28
|
+
where: inArray(schema.tasks.id, taskIds),
|
|
29
|
+
})
|
|
30
|
+
: [];
|
|
31
|
+
const taskMap = new Map(tasks.map((t) => [t.id, t]));
|
|
32
|
+
|
|
33
|
+
// Filter by projectIds if provided
|
|
34
|
+
const projectIdsParam = request.nextUrl.searchParams.get('projectIds');
|
|
35
|
+
const filterProjectIds = projectIdsParam ? projectIdsParam.split(',').filter(Boolean) : null;
|
|
36
|
+
|
|
37
|
+
const questions = allPending
|
|
38
|
+
.map((pending) => {
|
|
39
|
+
const attempt = attemptMap.get(pending.attemptId);
|
|
40
|
+
if (!attempt) return null;
|
|
41
|
+
const task = taskMap.get(attempt.taskId);
|
|
42
|
+
if (!task) return null;
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
attemptId: pending.attemptId,
|
|
46
|
+
taskId: task.id,
|
|
47
|
+
taskTitle: task.title,
|
|
48
|
+
projectId: task.projectId,
|
|
49
|
+
toolUseId: pending.toolUseId,
|
|
50
|
+
questions: pending.questions,
|
|
51
|
+
timestamp: pending.timestamp,
|
|
52
|
+
};
|
|
53
|
+
})
|
|
54
|
+
.filter((q): q is NonNullable<typeof q> => {
|
|
55
|
+
if (!q) return false;
|
|
56
|
+
if (filterProjectIds && !filterProjectIds.includes(q.projectId)) return false;
|
|
57
|
+
return true;
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
return NextResponse.json({ questions });
|
|
61
|
+
} catch (error) {
|
|
62
|
+
console.error('Error getting pending questions:', error);
|
|
63
|
+
return NextResponse.json(
|
|
64
|
+
{ error: 'Failed to get pending questions' },
|
|
65
|
+
{ status: 500 }
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
}
|