@winspan/claude-forge 8.19.0 → 8.25.1
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/dist/claudemd/resume-manager.d.ts.map +1 -1
- package/dist/claudemd/resume-manager.js +8 -7
- package/dist/claudemd/resume-manager.js.map +1 -1
- package/dist/cli/commands/agents.d.ts +3 -0
- package/dist/cli/commands/agents.d.ts.map +1 -0
- package/dist/cli/commands/agents.js +62 -0
- package/dist/cli/commands/agents.js.map +1 -0
- package/dist/cli/index.js +2 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/core/storage/schema.sql +3 -0
- package/dist/core/storage/sqlite.d.ts +4 -0
- package/dist/core/storage/sqlite.d.ts.map +1 -1
- package/dist/core/storage/sqlite.js +24 -3
- package/dist/core/storage/sqlite.js.map +1 -1
- package/dist/daemon/handlers/stop.d.ts +3 -1
- package/dist/daemon/handlers/stop.d.ts.map +1 -1
- package/dist/daemon/handlers/stop.js +13 -2
- package/dist/daemon/handlers/stop.js.map +1 -1
- package/dist/daemon/index.d.ts.map +1 -1
- package/dist/daemon/index.js +1 -1
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/routing-observer.d.ts +1 -0
- package/dist/daemon/routing-observer.d.ts.map +1 -1
- package/dist/daemon/routing-observer.js +35 -29
- package/dist/daemon/routing-observer.js.map +1 -1
- package/dist/intelligence/task-segmenter.d.ts +1 -0
- package/dist/intelligence/task-segmenter.d.ts.map +1 -1
- package/dist/intelligence/task-segmenter.js +10 -0
- package/dist/intelligence/task-segmenter.js.map +1 -1
- package/dist/web/server.d.ts +1 -0
- package/dist/web/server.d.ts.map +1 -1
- package/dist/web/server.js +711 -0
- package/dist/web/server.js.map +1 -1
- package/dist/web/static/index.html +566 -1
- package/package.json +1 -1
package/dist/web/server.js
CHANGED
|
@@ -13,21 +13,45 @@ import { Recommender } from '../engine/recommender.js';
|
|
|
13
13
|
import { logger } from '../core/utils/logger.js';
|
|
14
14
|
import { ErrorHandler } from '../core/utils/error-handler.js';
|
|
15
15
|
import { ConfigManager } from '../core/config.js';
|
|
16
|
+
import { ClaudeProvider } from '../core/ai/provider.js';
|
|
16
17
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
17
18
|
export class WebServer {
|
|
18
19
|
options;
|
|
19
20
|
app;
|
|
20
21
|
server = null;
|
|
21
22
|
agents;
|
|
23
|
+
router;
|
|
22
24
|
constructor(options) {
|
|
23
25
|
this.options = options;
|
|
24
26
|
this.app = express();
|
|
25
27
|
this.app.use(express.json());
|
|
26
28
|
this.agents = options.agents;
|
|
29
|
+
this.router = options.router;
|
|
27
30
|
this.setupRoutes();
|
|
28
31
|
}
|
|
29
32
|
setupRoutes() {
|
|
30
33
|
const { storage, ruleEngine } = this.options;
|
|
34
|
+
const resolvePatchTarget = (targetType, targetName) => {
|
|
35
|
+
if (targetType === 'agent') {
|
|
36
|
+
return {
|
|
37
|
+
filePath: path.join(homedir(), '.claude', 'agents', `${targetName}.md`),
|
|
38
|
+
backupDir: path.join(homedir(), '.claude-forge', 'backups', 'agents'),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
if (targetType === 'skill') {
|
|
42
|
+
return {
|
|
43
|
+
filePath: path.join(homedir(), '.claude', 'skills', `${targetName}.md`),
|
|
44
|
+
backupDir: path.join(homedir(), '.claude-forge', 'backups', 'skills'),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
if (targetType === 'routing_rule') {
|
|
48
|
+
return {
|
|
49
|
+
filePath: path.join(homedir(), '.claude-forge', 'routing.yaml'),
|
|
50
|
+
backupDir: path.join(homedir(), '.claude-forge', 'backups', 'routing'),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
throw new Error(`Unsupported targetType: ${targetType}`);
|
|
54
|
+
};
|
|
31
55
|
// Serve static files (support both dist/ and src/ layouts)
|
|
32
56
|
const candidates = [
|
|
33
57
|
path.join(__dirname, 'static'), // dist/web/static
|
|
@@ -436,6 +460,161 @@ export class WebServer {
|
|
|
436
460
|
byVersion,
|
|
437
461
|
});
|
|
438
462
|
});
|
|
463
|
+
// Performance analysis for the Agent Routing page
|
|
464
|
+
this.app.get('/api/routing/performance', (req, res) => {
|
|
465
|
+
const windowHours = parseInt(req.query.window || '168');
|
|
466
|
+
const minAttempts = Math.max(1, parseInt(req.query.minAttempts || '10'));
|
|
467
|
+
const since = Date.now() - windowHours * 3600 * 1000;
|
|
468
|
+
const events = storage.queryRoutingEvents({ since_ts: since, limit: 5000 });
|
|
469
|
+
const relevant = events.filter(e => e.routed_to_name);
|
|
470
|
+
const judged = relevant.filter(e => e.obeyed === 0 || e.obeyed === 1);
|
|
471
|
+
const obeyedCount = judged.filter(e => e.obeyed === 1).length;
|
|
472
|
+
const refusedCount = judged.filter(e => e.obeyed === 0).length;
|
|
473
|
+
const unknownCount = relevant.length - judged.length;
|
|
474
|
+
const latencies = relevant
|
|
475
|
+
.map(e => e.classification_ms)
|
|
476
|
+
.filter((ms) => typeof ms === 'number');
|
|
477
|
+
const executionLatencies = relevant
|
|
478
|
+
.map(e => e.total_execution_ms ?? ((typeof e.completed_ts === 'number') ? e.completed_ts - e.ts : null))
|
|
479
|
+
.filter((ms) => typeof ms === 'number');
|
|
480
|
+
const avgClassificationMs = latencies.length === 0
|
|
481
|
+
? null
|
|
482
|
+
: Math.round(latencies.reduce((sum, n) => sum + n, 0) / latencies.length);
|
|
483
|
+
const avgExecutionMs = executionLatencies.length === 0
|
|
484
|
+
? null
|
|
485
|
+
: Math.round(executionLatencies.reduce((sum, n) => sum + n, 0) / executionLatencies.length);
|
|
486
|
+
const sortedExecution = [...executionLatencies].sort((a, b) => a - b);
|
|
487
|
+
const p95ExecutionMs = sortedExecution.length === 0
|
|
488
|
+
? null
|
|
489
|
+
: sortedExecution[Math.min(sortedExecution.length - 1, Math.floor(sortedExecution.length * 0.95))];
|
|
490
|
+
const byAgentMap = new Map();
|
|
491
|
+
const dailyMap = new Map();
|
|
492
|
+
for (const e of relevant) {
|
|
493
|
+
const agent = e.routed_to_name ?? '—';
|
|
494
|
+
const agentBucket = byAgentMap.get(agent) ?? {
|
|
495
|
+
agent,
|
|
496
|
+
total: 0,
|
|
497
|
+
judged: 0,
|
|
498
|
+
obeyed: 0,
|
|
499
|
+
refused: 0,
|
|
500
|
+
unknown: 0,
|
|
501
|
+
latencySum: 0,
|
|
502
|
+
latencyCount: 0,
|
|
503
|
+
executionSum: 0,
|
|
504
|
+
executionCount: 0,
|
|
505
|
+
};
|
|
506
|
+
agentBucket.total++;
|
|
507
|
+
if (e.obeyed === 1) {
|
|
508
|
+
agentBucket.judged++;
|
|
509
|
+
agentBucket.obeyed++;
|
|
510
|
+
}
|
|
511
|
+
else if (e.obeyed === 0) {
|
|
512
|
+
agentBucket.judged++;
|
|
513
|
+
agentBucket.refused++;
|
|
514
|
+
}
|
|
515
|
+
else {
|
|
516
|
+
agentBucket.unknown++;
|
|
517
|
+
}
|
|
518
|
+
if (typeof e.classification_ms === 'number') {
|
|
519
|
+
agentBucket.latencySum += e.classification_ms;
|
|
520
|
+
agentBucket.latencyCount++;
|
|
521
|
+
}
|
|
522
|
+
const executionMs = e.total_execution_ms ?? ((typeof e.completed_ts === 'number') ? e.completed_ts - e.ts : null);
|
|
523
|
+
if (typeof executionMs === 'number') {
|
|
524
|
+
agentBucket.executionSum += executionMs;
|
|
525
|
+
agentBucket.executionCount++;
|
|
526
|
+
}
|
|
527
|
+
byAgentMap.set(agent, agentBucket);
|
|
528
|
+
const date = new Date(e.ts).toISOString().slice(0, 10);
|
|
529
|
+
const dayBucket = dailyMap.get(date) ?? {
|
|
530
|
+
date,
|
|
531
|
+
total: 0,
|
|
532
|
+
obeyed: 0,
|
|
533
|
+
refused: 0,
|
|
534
|
+
unknown: 0,
|
|
535
|
+
latencySum: 0,
|
|
536
|
+
latencyCount: 0,
|
|
537
|
+
executionSum: 0,
|
|
538
|
+
executionCount: 0,
|
|
539
|
+
};
|
|
540
|
+
dayBucket.total++;
|
|
541
|
+
if (e.obeyed === 1)
|
|
542
|
+
dayBucket.obeyed++;
|
|
543
|
+
else if (e.obeyed === 0)
|
|
544
|
+
dayBucket.refused++;
|
|
545
|
+
else
|
|
546
|
+
dayBucket.unknown++;
|
|
547
|
+
if (typeof e.classification_ms === 'number') {
|
|
548
|
+
dayBucket.latencySum += e.classification_ms;
|
|
549
|
+
dayBucket.latencyCount++;
|
|
550
|
+
}
|
|
551
|
+
if (typeof executionMs === 'number') {
|
|
552
|
+
dayBucket.executionSum += executionMs;
|
|
553
|
+
dayBucket.executionCount++;
|
|
554
|
+
}
|
|
555
|
+
dailyMap.set(date, dayBucket);
|
|
556
|
+
}
|
|
557
|
+
const byAgent = Array.from(byAgentMap.values())
|
|
558
|
+
.map(a => ({
|
|
559
|
+
agent: a.agent,
|
|
560
|
+
total: a.total,
|
|
561
|
+
judged: a.judged,
|
|
562
|
+
obeyed: a.obeyed,
|
|
563
|
+
refused: a.refused,
|
|
564
|
+
unknown: a.unknown,
|
|
565
|
+
obedienceRate: a.judged === 0 ? null : a.obeyed / a.judged,
|
|
566
|
+
refusalRate: a.judged === 0 ? null : a.refused / a.judged,
|
|
567
|
+
avgClassificationMs: a.latencyCount === 0 ? null : Math.round(a.latencySum / a.latencyCount),
|
|
568
|
+
avgExecutionMs: a.executionCount === 0 ? null : Math.round(a.executionSum / a.executionCount),
|
|
569
|
+
}))
|
|
570
|
+
.sort((a, b) => b.total - a.total);
|
|
571
|
+
const dailyTrend = Array.from(dailyMap.values())
|
|
572
|
+
.map(d => ({
|
|
573
|
+
date: d.date,
|
|
574
|
+
total: d.total,
|
|
575
|
+
obeyed: d.obeyed,
|
|
576
|
+
refused: d.refused,
|
|
577
|
+
unknown: d.unknown,
|
|
578
|
+
avgClassificationMs: d.latencyCount === 0 ? null : Math.round(d.latencySum / d.latencyCount),
|
|
579
|
+
avgExecutionMs: d.executionCount === 0 ? null : Math.round(d.executionSum / d.executionCount),
|
|
580
|
+
}))
|
|
581
|
+
.sort((a, b) => a.date.localeCompare(b.date));
|
|
582
|
+
const highRefusalAgents = byAgent
|
|
583
|
+
.filter(a => a.judged >= minAttempts && (a.refusalRate ?? 0) > 0)
|
|
584
|
+
.sort((a, b) => {
|
|
585
|
+
const rateDiff = (b.refusalRate ?? 0) - (a.refusalRate ?? 0);
|
|
586
|
+
return rateDiff !== 0 ? rateDiff : b.judged - a.judged;
|
|
587
|
+
})
|
|
588
|
+
.slice(0, 10)
|
|
589
|
+
.map(a => ({
|
|
590
|
+
agent: a.agent,
|
|
591
|
+
totalAttempts: a.judged,
|
|
592
|
+
obeyed: a.obeyed,
|
|
593
|
+
refused: a.refused,
|
|
594
|
+
refusalRate: a.refusalRate,
|
|
595
|
+
avgClassificationMs: a.avgClassificationMs,
|
|
596
|
+
avgExecutionMs: a.avgExecutionMs,
|
|
597
|
+
}));
|
|
598
|
+
res.json({
|
|
599
|
+
windowHours,
|
|
600
|
+
minAttempts,
|
|
601
|
+
summary: {
|
|
602
|
+
totalRouted: relevant.length,
|
|
603
|
+
totalJudged: judged.length,
|
|
604
|
+
obeyed: obeyedCount,
|
|
605
|
+
refused: refusedCount,
|
|
606
|
+
unknown: unknownCount,
|
|
607
|
+
obedienceRate: judged.length === 0 ? null : obeyedCount / judged.length,
|
|
608
|
+
refusalRate: judged.length === 0 ? null : refusedCount / judged.length,
|
|
609
|
+
avgClassificationMs,
|
|
610
|
+
avgExecutionMs,
|
|
611
|
+
p95ExecutionMs,
|
|
612
|
+
},
|
|
613
|
+
byAgent,
|
|
614
|
+
dailyTrend,
|
|
615
|
+
highRefusalAgents,
|
|
616
|
+
});
|
|
617
|
+
});
|
|
439
618
|
// Recent routing events (timeline / detail)
|
|
440
619
|
this.app.get('/api/routing/events', (req, res) => {
|
|
441
620
|
const limit = parseInt(req.query.limit || '50');
|
|
@@ -841,6 +1020,236 @@ export class WebServer {
|
|
|
841
1020
|
res.status(500).json({ error: String(err) });
|
|
842
1021
|
}
|
|
843
1022
|
});
|
|
1023
|
+
// AI-assisted optimization recommendations
|
|
1024
|
+
this.app.get('/api/routing/ai-optimization', async (req, res) => {
|
|
1025
|
+
const { router, agents } = this.options;
|
|
1026
|
+
if (!router || !agents) {
|
|
1027
|
+
res.status(400).json({ error: 'router/agents not injected' });
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
const windowHours = Math.max(1, Math.min(720, parseInt(req.query.window || '168')));
|
|
1031
|
+
const minAttempts = Math.max(1, parseInt(req.query.minAttempts || '10'));
|
|
1032
|
+
const since = Date.now() - windowHours * 3600 * 1000;
|
|
1033
|
+
try {
|
|
1034
|
+
const config = new ConfigManager().get();
|
|
1035
|
+
const apiKey = config.distill.api_key || process.env.ANTHROPIC_API_KEY || '';
|
|
1036
|
+
if (!apiKey) {
|
|
1037
|
+
res.status(400).json({ error: 'AI API key not configured' });
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
1040
|
+
const ai = new ClaudeProvider(apiKey, config.distill.model, config.distill.base_url);
|
|
1041
|
+
const recommender = new Recommender(storage, router, agents, { windowMs: windowHours * 3600 * 1000 });
|
|
1042
|
+
const ruleRecommendations = recommender.analyze();
|
|
1043
|
+
const performanceRes = await (async () => {
|
|
1044
|
+
const events = storage.queryRoutingEvents({ since_ts: since, limit: 5000 });
|
|
1045
|
+
const relevant = events.filter(e => e.routed_to_name);
|
|
1046
|
+
const judged = relevant.filter(e => e.obeyed === 0 || e.obeyed === 1);
|
|
1047
|
+
const byAgent = new Map();
|
|
1048
|
+
for (const e of relevant) {
|
|
1049
|
+
const key = e.routed_to_name ?? '—';
|
|
1050
|
+
const bucket = byAgent.get(key) ?? { total: 0, judged: 0, obeyed: 0, refused: 0, latencySum: 0, latencyCount: 0 };
|
|
1051
|
+
bucket.total++;
|
|
1052
|
+
if (e.obeyed === 1) {
|
|
1053
|
+
bucket.judged++;
|
|
1054
|
+
bucket.obeyed++;
|
|
1055
|
+
}
|
|
1056
|
+
else if (e.obeyed === 0) {
|
|
1057
|
+
bucket.judged++;
|
|
1058
|
+
bucket.refused++;
|
|
1059
|
+
}
|
|
1060
|
+
if (typeof e.classification_ms === 'number') {
|
|
1061
|
+
bucket.latencySum += e.classification_ms;
|
|
1062
|
+
bucket.latencyCount++;
|
|
1063
|
+
}
|
|
1064
|
+
byAgent.set(key, bucket);
|
|
1065
|
+
}
|
|
1066
|
+
return {
|
|
1067
|
+
totalRouted: relevant.length,
|
|
1068
|
+
totalJudged: judged.length,
|
|
1069
|
+
byAgent: Array.from(byAgent.entries()).map(([agent, b]) => ({
|
|
1070
|
+
agent,
|
|
1071
|
+
total: b.total,
|
|
1072
|
+
judged: b.judged,
|
|
1073
|
+
obeyed: b.obeyed,
|
|
1074
|
+
refused: b.refused,
|
|
1075
|
+
refusalRate: b.judged === 0 ? null : b.refused / b.judged,
|
|
1076
|
+
avgClassificationMs: b.latencyCount === 0 ? null : Math.round(b.latencySum / b.latencyCount),
|
|
1077
|
+
})).sort((a, b) => (b.refusalRate ?? 0) - (a.refusalRate ?? 0)),
|
|
1078
|
+
};
|
|
1079
|
+
})();
|
|
1080
|
+
const highRefusalAgents = performanceRes.byAgent
|
|
1081
|
+
.filter(a => a.judged >= minAttempts && (a.refusalRate ?? 0) > 0)
|
|
1082
|
+
.slice(0, 10);
|
|
1083
|
+
const topViolations = (await (async () => {
|
|
1084
|
+
const events = storage.queryRoutingEvents({ since_ts: since, obeyed: 0, limit: 1000 });
|
|
1085
|
+
const patterns = new Map();
|
|
1086
|
+
for (const e of events) {
|
|
1087
|
+
let taskType = 'unknown';
|
|
1088
|
+
try {
|
|
1089
|
+
const parsed = JSON.parse(e.intent_json ?? '{}');
|
|
1090
|
+
if (typeof parsed.taskType === 'string')
|
|
1091
|
+
taskType = parsed.taskType;
|
|
1092
|
+
}
|
|
1093
|
+
catch { /* ignore */ }
|
|
1094
|
+
const key = `${taskType}__${e.routed_to_name ?? '—'}`;
|
|
1095
|
+
const p = patterns.get(key) ?? { taskType, agent: e.routed_to_name ?? '—', total: 0, refusals: 0, refusalRate: 0, samples: [] };
|
|
1096
|
+
p.total++;
|
|
1097
|
+
p.refusals++;
|
|
1098
|
+
if (p.samples.length < 3)
|
|
1099
|
+
p.samples.push(e.prompt.slice(0, 180));
|
|
1100
|
+
patterns.set(key, p);
|
|
1101
|
+
}
|
|
1102
|
+
return Array.from(patterns.values()).map(p => ({
|
|
1103
|
+
...p,
|
|
1104
|
+
refusalRate: p.total === 0 ? 0 : p.refusals / p.total,
|
|
1105
|
+
})).sort((a, b) => b.refusalRate - a.refusalRate).slice(0, 5);
|
|
1106
|
+
})());
|
|
1107
|
+
const prompt = [
|
|
1108
|
+
'You are reviewing Claude Forge routing performance and should produce practical improvement suggestions.',
|
|
1109
|
+
'Return ONLY valid JSON with keys: summary, priorities, suggestedChanges.',
|
|
1110
|
+
'',
|
|
1111
|
+
'Context:',
|
|
1112
|
+
`- windowHours: ${windowHours}`,
|
|
1113
|
+
`- minAttempts: ${minAttempts}`,
|
|
1114
|
+
`- totalRouted: ${performanceRes.totalRouted}`,
|
|
1115
|
+
`- totalJudged: ${performanceRes.totalJudged}`,
|
|
1116
|
+
'',
|
|
1117
|
+
'Performance by agent:',
|
|
1118
|
+
JSON.stringify(highRefusalAgents, null, 2),
|
|
1119
|
+
'',
|
|
1120
|
+
'Top refusal patterns:',
|
|
1121
|
+
JSON.stringify(topViolations, null, 2),
|
|
1122
|
+
'',
|
|
1123
|
+
'Routing rule recommendations:',
|
|
1124
|
+
JSON.stringify(ruleRecommendations.slice(0, 8), null, 2),
|
|
1125
|
+
'',
|
|
1126
|
+
'Please provide:',
|
|
1127
|
+
'- summary: 2-3 sentences summarizing the main issues',
|
|
1128
|
+
'- priorities: array of { area, finding, impact, confidence }',
|
|
1129
|
+
'- suggestedChanges: array of { targetType: agent|skill|routing_rule, targetName, recommendation, rationale, expectedBenefit }',
|
|
1130
|
+
].join('\n');
|
|
1131
|
+
const raw = await ai.complete(prompt, { maxTokens: 2500 });
|
|
1132
|
+
const cleaned = raw.replace(/```json\s*/g, '').replace(/```\s*/g, '');
|
|
1133
|
+
const match = cleaned.match(/\{[\s\S]*\}/);
|
|
1134
|
+
if (!match) {
|
|
1135
|
+
res.status(500).json({ error: 'AI did not return JSON', raw });
|
|
1136
|
+
return;
|
|
1137
|
+
}
|
|
1138
|
+
const parsed = JSON.parse(match[0]);
|
|
1139
|
+
res.json({
|
|
1140
|
+
windowHours,
|
|
1141
|
+
minAttempts,
|
|
1142
|
+
generatedAt: new Date().toISOString(),
|
|
1143
|
+
summary: parsed.summary ?? '',
|
|
1144
|
+
priorities: Array.isArray(parsed.priorities) ? parsed.priorities : [],
|
|
1145
|
+
suggestedChanges: Array.isArray(parsed.suggestedChanges) ? parsed.suggestedChanges : [],
|
|
1146
|
+
evidence: {
|
|
1147
|
+
highRefusalAgents,
|
|
1148
|
+
topViolations,
|
|
1149
|
+
ruleRecommendations: ruleRecommendations.slice(0, 8),
|
|
1150
|
+
},
|
|
1151
|
+
});
|
|
1152
|
+
}
|
|
1153
|
+
catch (err) {
|
|
1154
|
+
logger.warn(`[Web] Failed to generate AI optimization: ${err}`);
|
|
1155
|
+
res.status(500).json({ error: String(err) });
|
|
1156
|
+
}
|
|
1157
|
+
});
|
|
1158
|
+
// ── Patch APIs ────────────────────────────────────────────────────────
|
|
1159
|
+
// POST /api/patch/preview — generate structured patch preview
|
|
1160
|
+
this.app.post('/api/patch/preview', async (req, res) => {
|
|
1161
|
+
const { targetType, targetName, recommendation, rationale } = req.body ?? {};
|
|
1162
|
+
if (!targetType || !targetName || !recommendation) {
|
|
1163
|
+
res.status(400).json({ error: 'targetType, targetName, and recommendation are required' });
|
|
1164
|
+
return;
|
|
1165
|
+
}
|
|
1166
|
+
try {
|
|
1167
|
+
const config = new ConfigManager().get();
|
|
1168
|
+
const apiKey = config.distill.api_key || process.env.ANTHROPIC_API_KEY || '';
|
|
1169
|
+
if (!apiKey) {
|
|
1170
|
+
res.status(400).json({ error: 'AI API key not configured' });
|
|
1171
|
+
return;
|
|
1172
|
+
}
|
|
1173
|
+
const { filePath } = resolvePatchTarget(targetType, targetName);
|
|
1174
|
+
if (!fs.existsSync(filePath)) {
|
|
1175
|
+
res.status(404).json({ error: `Target file not found: ${filePath}` });
|
|
1176
|
+
return;
|
|
1177
|
+
}
|
|
1178
|
+
const currentContent = fs.readFileSync(filePath, 'utf-8');
|
|
1179
|
+
const ai = new ClaudeProvider(apiKey, config.distill.model, config.distill.base_url);
|
|
1180
|
+
const prompt = `You are a code/config optimization assistant. Given the current file content and a recommended change, generate the updated content.
|
|
1181
|
+
|
|
1182
|
+
Current file (${targetType}/${targetName}):
|
|
1183
|
+
\`\`\`
|
|
1184
|
+
${currentContent}
|
|
1185
|
+
\`\`\`
|
|
1186
|
+
|
|
1187
|
+
Recommendation: ${recommendation}
|
|
1188
|
+
${rationale ? `Rationale: ${rationale}` : ''}
|
|
1189
|
+
|
|
1190
|
+
Return ONLY a JSON object with this exact structure (no markdown, no explanation):
|
|
1191
|
+
{
|
|
1192
|
+
"summary": "brief description of what changed",
|
|
1193
|
+
"afterContent": "the complete updated file content",
|
|
1194
|
+
"risk": "low|medium|high"
|
|
1195
|
+
}`;
|
|
1196
|
+
const response = await ai.complete(prompt, { maxTokens: 4096 });
|
|
1197
|
+
const parsed = JSON.parse(response.trim());
|
|
1198
|
+
res.json({
|
|
1199
|
+
targetType,
|
|
1200
|
+
targetName,
|
|
1201
|
+
filePath,
|
|
1202
|
+
before: currentContent,
|
|
1203
|
+
after: parsed.afterContent,
|
|
1204
|
+
summary: parsed.summary,
|
|
1205
|
+
risk: parsed.risk || 'medium',
|
|
1206
|
+
recommendation,
|
|
1207
|
+
rationale: rationale || null,
|
|
1208
|
+
});
|
|
1209
|
+
}
|
|
1210
|
+
catch (err) {
|
|
1211
|
+
logger.warn(`[Web] Patch preview failed: ${err}`);
|
|
1212
|
+
res.status(500).json({ error: String(err) });
|
|
1213
|
+
}
|
|
1214
|
+
});
|
|
1215
|
+
// POST /api/patch/apply — apply patch with backup
|
|
1216
|
+
this.app.post('/api/patch/apply', (req, res) => {
|
|
1217
|
+
const { targetType, targetName, afterContent } = req.body ?? {};
|
|
1218
|
+
if (!targetType || !targetName || typeof afterContent !== 'string') {
|
|
1219
|
+
res.status(400).json({ error: 'targetType, targetName, and afterContent are required' });
|
|
1220
|
+
return;
|
|
1221
|
+
}
|
|
1222
|
+
try {
|
|
1223
|
+
const { filePath, backupDir } = resolvePatchTarget(targetType, targetName);
|
|
1224
|
+
if (!fs.existsSync(filePath)) {
|
|
1225
|
+
res.status(404).json({ error: `Target file not found: ${filePath}` });
|
|
1226
|
+
return;
|
|
1227
|
+
}
|
|
1228
|
+
// Create backup
|
|
1229
|
+
if (!fs.existsSync(backupDir)) {
|
|
1230
|
+
fs.mkdirSync(backupDir, { recursive: true });
|
|
1231
|
+
}
|
|
1232
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
1233
|
+
const backupName = `${targetName}_${timestamp}${path.extname(filePath)}`;
|
|
1234
|
+
const backupPath = path.join(backupDir, backupName);
|
|
1235
|
+
fs.copyFileSync(filePath, backupPath);
|
|
1236
|
+
// Apply patch
|
|
1237
|
+
fs.writeFileSync(filePath, afterContent, 'utf-8');
|
|
1238
|
+
logger.info(`[Web] Patch applied to ${targetType}/${targetName}, backup: ${backupPath}`);
|
|
1239
|
+
res.json({
|
|
1240
|
+
success: true,
|
|
1241
|
+
targetType,
|
|
1242
|
+
targetName,
|
|
1243
|
+
filePath,
|
|
1244
|
+
backupPath,
|
|
1245
|
+
timestamp,
|
|
1246
|
+
});
|
|
1247
|
+
}
|
|
1248
|
+
catch (err) {
|
|
1249
|
+
logger.warn(`[Web] Patch apply failed: ${err}`);
|
|
1250
|
+
res.status(500).json({ error: String(err) });
|
|
1251
|
+
}
|
|
1252
|
+
});
|
|
844
1253
|
// ── AI Configuration APIs ─────────────────────────────────────────────
|
|
845
1254
|
// GET /api/config/ai — read current AI config (mask apiKey)
|
|
846
1255
|
this.app.get('/api/config/ai', (_req, res) => {
|
|
@@ -1178,6 +1587,179 @@ export class WebServer {
|
|
|
1178
1587
|
res.status(500).json({ error: String(err) });
|
|
1179
1588
|
}
|
|
1180
1589
|
});
|
|
1590
|
+
// ── Version Management APIs ────────────────────────────────────────────────
|
|
1591
|
+
// GET /api/agents/:name/versions — list backup versions
|
|
1592
|
+
this.app.get('/api/agents/:name/versions', (req, res) => {
|
|
1593
|
+
try {
|
|
1594
|
+
const { name } = req.params;
|
|
1595
|
+
const backupDir = path.join(homedir(), '.claude-forge', 'backups', 'agents');
|
|
1596
|
+
if (!fs.existsSync(backupDir)) {
|
|
1597
|
+
res.json({ versions: [] });
|
|
1598
|
+
return;
|
|
1599
|
+
}
|
|
1600
|
+
const files = fs.readdirSync(backupDir)
|
|
1601
|
+
.filter(f => f.startsWith(`${name}-`) && f.endsWith('.md'))
|
|
1602
|
+
.map(f => {
|
|
1603
|
+
const filePath = path.join(backupDir, f);
|
|
1604
|
+
const stats = fs.statSync(filePath);
|
|
1605
|
+
const timestampMatch = f.match(/-(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z)\.md$/);
|
|
1606
|
+
return {
|
|
1607
|
+
filename: f,
|
|
1608
|
+
timestamp: timestampMatch ? timestampMatch[1].replace(/-/g, ':').replace(/T(\d{2}):(\d{2}):(\d{2})/, 'T$1:$2:$3') : '',
|
|
1609
|
+
size: stats.size,
|
|
1610
|
+
mtime: stats.mtime.toISOString(),
|
|
1611
|
+
};
|
|
1612
|
+
})
|
|
1613
|
+
.sort((a, b) => new Date(b.mtime).getTime() - new Date(a.mtime).getTime());
|
|
1614
|
+
res.json({ versions: files });
|
|
1615
|
+
}
|
|
1616
|
+
catch (err) {
|
|
1617
|
+
logger.warn(`[Web] Failed to list agent versions: ${err}`);
|
|
1618
|
+
res.status(500).json({ error: String(err) });
|
|
1619
|
+
}
|
|
1620
|
+
});
|
|
1621
|
+
// GET /api/agents/:name/versions/:timestamp — get specific version content
|
|
1622
|
+
this.app.get('/api/agents/:name/versions/:timestamp', (req, res) => {
|
|
1623
|
+
try {
|
|
1624
|
+
const { name, timestamp } = req.params;
|
|
1625
|
+
const backupDir = path.join(homedir(), '.claude-forge', 'backups', 'agents');
|
|
1626
|
+
const filename = `${name}-${timestamp}.md`;
|
|
1627
|
+
const filePath = path.join(backupDir, filename);
|
|
1628
|
+
if (!fs.existsSync(filePath)) {
|
|
1629
|
+
res.status(404).json({ error: 'Version not found' });
|
|
1630
|
+
return;
|
|
1631
|
+
}
|
|
1632
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
1633
|
+
res.json({ content });
|
|
1634
|
+
}
|
|
1635
|
+
catch (err) {
|
|
1636
|
+
logger.warn(`[Web] Failed to get agent version: ${err}`);
|
|
1637
|
+
res.status(500).json({ error: String(err) });
|
|
1638
|
+
}
|
|
1639
|
+
});
|
|
1640
|
+
// POST /api/agents/:name/rollback — rollback to a specific version
|
|
1641
|
+
this.app.post('/api/agents/:name/rollback', (req, res) => {
|
|
1642
|
+
try {
|
|
1643
|
+
const { name } = req.params;
|
|
1644
|
+
const { timestamp } = req.body;
|
|
1645
|
+
if (!timestamp) {
|
|
1646
|
+
res.status(400).json({ error: 'Missing timestamp' });
|
|
1647
|
+
return;
|
|
1648
|
+
}
|
|
1649
|
+
const agentsDir = path.join(homedir(), '.claude', 'agents');
|
|
1650
|
+
const currentPath = path.join(agentsDir, `${name}.md`);
|
|
1651
|
+
const backupDir = path.join(homedir(), '.claude-forge', 'backups', 'agents');
|
|
1652
|
+
const versionPath = path.join(backupDir, `${name}-${timestamp}.md`);
|
|
1653
|
+
if (!fs.existsSync(currentPath)) {
|
|
1654
|
+
res.status(404).json({ error: 'Agent not found' });
|
|
1655
|
+
return;
|
|
1656
|
+
}
|
|
1657
|
+
if (!fs.existsSync(versionPath)) {
|
|
1658
|
+
res.status(404).json({ error: 'Version not found' });
|
|
1659
|
+
return;
|
|
1660
|
+
}
|
|
1661
|
+
// Backup current version before rollback
|
|
1662
|
+
fs.mkdirSync(backupDir, { recursive: true });
|
|
1663
|
+
const rollbackTimestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
1664
|
+
const rollbackBackupPath = path.join(backupDir, `${name}-${rollbackTimestamp}.md`);
|
|
1665
|
+
fs.copyFileSync(currentPath, rollbackBackupPath);
|
|
1666
|
+
// Restore version
|
|
1667
|
+
const versionContent = fs.readFileSync(versionPath, 'utf-8');
|
|
1668
|
+
fs.writeFileSync(currentPath, versionContent, 'utf-8');
|
|
1669
|
+
logger.info(`[Web] Rolled back agent ${name} to ${timestamp} (backup: ${rollbackBackupPath})`);
|
|
1670
|
+
res.json({ success: true, backup: rollbackBackupPath });
|
|
1671
|
+
}
|
|
1672
|
+
catch (err) {
|
|
1673
|
+
logger.warn(`[Web] Failed to rollback agent: ${err}`);
|
|
1674
|
+
res.status(500).json({ error: String(err) });
|
|
1675
|
+
}
|
|
1676
|
+
});
|
|
1677
|
+
// GET /api/skills/:name/versions — list backup versions
|
|
1678
|
+
this.app.get('/api/skills/:name/versions', (req, res) => {
|
|
1679
|
+
try {
|
|
1680
|
+
const { name } = req.params;
|
|
1681
|
+
const backupDir = path.join(homedir(), '.claude-forge', 'backups', 'skills');
|
|
1682
|
+
if (!fs.existsSync(backupDir)) {
|
|
1683
|
+
res.json({ versions: [] });
|
|
1684
|
+
return;
|
|
1685
|
+
}
|
|
1686
|
+
const files = fs.readdirSync(backupDir)
|
|
1687
|
+
.filter(f => f.startsWith(`${name}-`) && f.endsWith('.md'))
|
|
1688
|
+
.map(f => {
|
|
1689
|
+
const filePath = path.join(backupDir, f);
|
|
1690
|
+
const stats = fs.statSync(filePath);
|
|
1691
|
+
const timestampMatch = f.match(/-(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z)\.md$/);
|
|
1692
|
+
return {
|
|
1693
|
+
filename: f,
|
|
1694
|
+
timestamp: timestampMatch ? timestampMatch[1].replace(/-/g, ':').replace(/T(\d{2}):(\d{2}):(\d{2})/, 'T$1:$2:$3') : '',
|
|
1695
|
+
size: stats.size,
|
|
1696
|
+
mtime: stats.mtime.toISOString(),
|
|
1697
|
+
};
|
|
1698
|
+
})
|
|
1699
|
+
.sort((a, b) => new Date(b.mtime).getTime() - new Date(a.mtime).getTime());
|
|
1700
|
+
res.json({ versions: files });
|
|
1701
|
+
}
|
|
1702
|
+
catch (err) {
|
|
1703
|
+
logger.warn(`[Web] Failed to list skill versions: ${err}`);
|
|
1704
|
+
res.status(500).json({ error: String(err) });
|
|
1705
|
+
}
|
|
1706
|
+
});
|
|
1707
|
+
// GET /api/skills/:name/versions/:timestamp — get specific version content
|
|
1708
|
+
this.app.get('/api/skills/:name/versions/:timestamp', (req, res) => {
|
|
1709
|
+
try {
|
|
1710
|
+
const { name, timestamp } = req.params;
|
|
1711
|
+
const backupDir = path.join(homedir(), '.claude-forge', 'backups', 'skills');
|
|
1712
|
+
const filename = `${name}-${timestamp}.md`;
|
|
1713
|
+
const filePath = path.join(backupDir, filename);
|
|
1714
|
+
if (!fs.existsSync(filePath)) {
|
|
1715
|
+
res.status(404).json({ error: 'Version not found' });
|
|
1716
|
+
return;
|
|
1717
|
+
}
|
|
1718
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
1719
|
+
res.json({ content });
|
|
1720
|
+
}
|
|
1721
|
+
catch (err) {
|
|
1722
|
+
logger.warn(`[Web] Failed to get skill version: ${err}`);
|
|
1723
|
+
res.status(500).json({ error: String(err) });
|
|
1724
|
+
}
|
|
1725
|
+
});
|
|
1726
|
+
// POST /api/skills/:name/rollback — rollback to a specific version
|
|
1727
|
+
this.app.post('/api/skills/:name/rollback', (req, res) => {
|
|
1728
|
+
try {
|
|
1729
|
+
const { name } = req.params;
|
|
1730
|
+
const { timestamp } = req.body;
|
|
1731
|
+
if (!timestamp) {
|
|
1732
|
+
res.status(400).json({ error: 'Missing timestamp' });
|
|
1733
|
+
return;
|
|
1734
|
+
}
|
|
1735
|
+
const skillsDir = path.join(homedir(), '.claude', 'skills');
|
|
1736
|
+
const currentPath = path.join(skillsDir, `${name}.md`);
|
|
1737
|
+
const backupDir = path.join(homedir(), '.claude-forge', 'backups', 'skills');
|
|
1738
|
+
const versionPath = path.join(backupDir, `${name}-${timestamp}.md`);
|
|
1739
|
+
if (!fs.existsSync(currentPath)) {
|
|
1740
|
+
res.status(404).json({ error: 'Skill not found' });
|
|
1741
|
+
return;
|
|
1742
|
+
}
|
|
1743
|
+
if (!fs.existsSync(versionPath)) {
|
|
1744
|
+
res.status(404).json({ error: 'Version not found' });
|
|
1745
|
+
return;
|
|
1746
|
+
}
|
|
1747
|
+
// Backup current version before rollback
|
|
1748
|
+
fs.mkdirSync(backupDir, { recursive: true });
|
|
1749
|
+
const rollbackTimestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
1750
|
+
const rollbackBackupPath = path.join(backupDir, `${name}-${rollbackTimestamp}.md`);
|
|
1751
|
+
fs.copyFileSync(currentPath, rollbackBackupPath);
|
|
1752
|
+
// Restore version
|
|
1753
|
+
const versionContent = fs.readFileSync(versionPath, 'utf-8');
|
|
1754
|
+
fs.writeFileSync(currentPath, versionContent, 'utf-8');
|
|
1755
|
+
logger.info(`[Web] Rolled back skill ${name} to ${timestamp} (backup: ${rollbackBackupPath})`);
|
|
1756
|
+
res.json({ success: true, backup: rollbackBackupPath });
|
|
1757
|
+
}
|
|
1758
|
+
catch (err) {
|
|
1759
|
+
logger.warn(`[Web] Failed to rollback skill: ${err}`);
|
|
1760
|
+
res.status(500).json({ error: String(err) });
|
|
1761
|
+
}
|
|
1762
|
+
});
|
|
1181
1763
|
// ── Execution Trace APIs ──────────────────────────────────────────────────
|
|
1182
1764
|
// GET /api/execution-trace — list execution traces with routing info
|
|
1183
1765
|
this.app.get('/api/execution-trace', (req, res) => {
|
|
@@ -1200,6 +1782,10 @@ export class WebServer {
|
|
|
1200
1782
|
re.classification_ms,
|
|
1201
1783
|
re.fallback_used,
|
|
1202
1784
|
re.first_tool_name,
|
|
1785
|
+
re.first_tool_ts,
|
|
1786
|
+
re.completed_ts,
|
|
1787
|
+
re.total_execution_ms,
|
|
1788
|
+
re.completion_reason,
|
|
1203
1789
|
re.downstream_task_chain,
|
|
1204
1790
|
s.status as session_status,
|
|
1205
1791
|
s.start_time,
|
|
@@ -1236,6 +1822,10 @@ export class WebServer {
|
|
|
1236
1822
|
classificationMs: row.classification_ms,
|
|
1237
1823
|
fallbackUsed: row.fallback_used === 1,
|
|
1238
1824
|
firstTool: row.first_tool_name,
|
|
1825
|
+
firstToolTs: row.first_tool_ts,
|
|
1826
|
+
completedTs: row.completed_ts,
|
|
1827
|
+
totalExecutionMs: row.total_execution_ms,
|
|
1828
|
+
completionReason: row.completion_reason,
|
|
1239
1829
|
taskChain: row.downstream_task_chain ? JSON.parse(row.downstream_task_chain) : [],
|
|
1240
1830
|
sessionStatus: row.session_status,
|
|
1241
1831
|
sessionStart: row.start_time,
|
|
@@ -1279,6 +1869,7 @@ export class WebServer {
|
|
|
1279
1869
|
res.json({
|
|
1280
1870
|
routing: {
|
|
1281
1871
|
prompt: routing.prompt,
|
|
1872
|
+
timestamp: routing.ts,
|
|
1282
1873
|
intent: JSON.parse(routing.intent_json || '{}'),
|
|
1283
1874
|
routedToType: routing.routed_to_type,
|
|
1284
1875
|
routedToName: routing.routed_to_name,
|
|
@@ -1288,6 +1879,9 @@ export class WebServer {
|
|
|
1288
1879
|
refusalReason: routing.refusal_reason,
|
|
1289
1880
|
firstTool: routing.first_tool_name,
|
|
1290
1881
|
firstToolTs: routing.first_tool_ts,
|
|
1882
|
+
completedTs: routing.completed_ts,
|
|
1883
|
+
totalExecutionMs: routing.total_execution_ms,
|
|
1884
|
+
completionReason: routing.completion_reason,
|
|
1291
1885
|
taskChain: routing.downstream_task_chain ? JSON.parse(routing.downstream_task_chain) : [],
|
|
1292
1886
|
},
|
|
1293
1887
|
events,
|
|
@@ -1299,6 +1893,123 @@ export class WebServer {
|
|
|
1299
1893
|
res.status(500).json({ error: String(err) });
|
|
1300
1894
|
}
|
|
1301
1895
|
});
|
|
1896
|
+
// GET /api/execution-trace/:session_id/status — current status snapshot
|
|
1897
|
+
this.app.get('/api/execution-trace/:session_id/status', (req, res) => {
|
|
1898
|
+
try {
|
|
1899
|
+
const { session_id } = req.params;
|
|
1900
|
+
const db = storage.getDatabase();
|
|
1901
|
+
const routing = db.prepare(`SELECT * FROM routing_events WHERE session_id = ? ORDER BY ts DESC LIMIT 1`).get(session_id);
|
|
1902
|
+
if (!routing) {
|
|
1903
|
+
res.status(404).json({ error: 'Trace not found' });
|
|
1904
|
+
return;
|
|
1905
|
+
}
|
|
1906
|
+
const latestToolEvent = db.prepare(`
|
|
1907
|
+
SELECT tool, success, error, timestamp
|
|
1908
|
+
FROM v2_tool_events
|
|
1909
|
+
WHERE session_id = ? AND timestamp >= ?
|
|
1910
|
+
ORDER BY timestamp DESC LIMIT 1
|
|
1911
|
+
`).get(session_id, routing.ts);
|
|
1912
|
+
const latestDecision = db.prepare(`
|
|
1913
|
+
SELECT level, reason, timestamp
|
|
1914
|
+
FROM v2_decisions
|
|
1915
|
+
WHERE session_id = ? AND timestamp >= ?
|
|
1916
|
+
ORDER BY timestamp DESC LIMIT 1
|
|
1917
|
+
`).get(session_id, routing.ts);
|
|
1918
|
+
let status = 'routing';
|
|
1919
|
+
if (latestDecision?.level === 'block' || latestToolEvent?.success === 0)
|
|
1920
|
+
status = 'failed';
|
|
1921
|
+
else if (routing.completed_ts)
|
|
1922
|
+
status = 'completed';
|
|
1923
|
+
else if (routing.first_tool_ts || latestToolEvent)
|
|
1924
|
+
status = 'executing';
|
|
1925
|
+
res.json({
|
|
1926
|
+
status,
|
|
1927
|
+
timestamp: routing.ts,
|
|
1928
|
+
firstTool: routing.first_tool_name ?? latestToolEvent?.tool ?? null,
|
|
1929
|
+
firstToolTs: routing.first_tool_ts ?? latestToolEvent?.timestamp ?? null,
|
|
1930
|
+
completedTs: routing.completed_ts ?? null,
|
|
1931
|
+
totalExecutionMs: routing.total_execution_ms ?? null,
|
|
1932
|
+
completionReason: routing.completion_reason ?? null,
|
|
1933
|
+
error: latestToolEvent?.success === 0 ? latestToolEvent.error : (latestDecision?.level === 'block' ? latestDecision.reason : null),
|
|
1934
|
+
});
|
|
1935
|
+
}
|
|
1936
|
+
catch (err) {
|
|
1937
|
+
logger.warn(`[Web] Failed to get trace status for ${req.params.session_id}: ${err}`);
|
|
1938
|
+
res.status(500).json({ error: String(err) });
|
|
1939
|
+
}
|
|
1940
|
+
});
|
|
1941
|
+
// SSE: execution trace live status stream
|
|
1942
|
+
this.app.get('/api/execution-trace/stream', (req, res) => {
|
|
1943
|
+
res.writeHead(200, {
|
|
1944
|
+
'Content-Type': 'text/event-stream',
|
|
1945
|
+
'Cache-Control': 'no-cache',
|
|
1946
|
+
Connection: 'keep-alive',
|
|
1947
|
+
});
|
|
1948
|
+
res.write('data: {"type":"connected"}\n\n');
|
|
1949
|
+
const filterSession = req.query.session;
|
|
1950
|
+
const readOnlyTools = new Set(['Read', 'Grep', 'Glob', 'LS', 'NotebookRead', 'WebFetch', 'WebSearch']);
|
|
1951
|
+
const writeStatus = (payload) => {
|
|
1952
|
+
if (filterSession && payload.sessionId !== filterSession)
|
|
1953
|
+
return;
|
|
1954
|
+
res.write(`data: ${JSON.stringify(payload)}\n\n`);
|
|
1955
|
+
};
|
|
1956
|
+
const onEvent = (event) => {
|
|
1957
|
+
const hookType = event.hook_type;
|
|
1958
|
+
const toolName = event.tool_name;
|
|
1959
|
+
const sessionId = event.session_id;
|
|
1960
|
+
const timestamp = event.timestamp;
|
|
1961
|
+
if (!sessionId || !hookType)
|
|
1962
|
+
return;
|
|
1963
|
+
if (hookType === 'UserPromptSubmit') {
|
|
1964
|
+
writeStatus({ type: 'execution-status', status: 'routing', sessionId, timestamp, prompt: event.user_prompt ?? null });
|
|
1965
|
+
return;
|
|
1966
|
+
}
|
|
1967
|
+
if (hookType === 'PreToolUse' && toolName && !readOnlyTools.has(toolName)) {
|
|
1968
|
+
writeStatus({ type: 'execution-status', status: 'executing', sessionId, timestamp, tool: toolName });
|
|
1969
|
+
return;
|
|
1970
|
+
}
|
|
1971
|
+
if (hookType === 'Stop') {
|
|
1972
|
+
writeStatus({ type: 'execution-status', status: 'completed', sessionId, timestamp });
|
|
1973
|
+
}
|
|
1974
|
+
};
|
|
1975
|
+
const onToolEvent = (event) => {
|
|
1976
|
+
const sessionId = event.session_id;
|
|
1977
|
+
if (!sessionId)
|
|
1978
|
+
return;
|
|
1979
|
+
if (event.success === false || event.success === 0) {
|
|
1980
|
+
writeStatus({
|
|
1981
|
+
type: 'execution-status',
|
|
1982
|
+
status: 'failed',
|
|
1983
|
+
sessionId,
|
|
1984
|
+
timestamp: event.timestamp ?? Date.now(),
|
|
1985
|
+
tool: event.tool ?? null,
|
|
1986
|
+
error: event.error ?? null,
|
|
1987
|
+
});
|
|
1988
|
+
}
|
|
1989
|
+
};
|
|
1990
|
+
const onDecision = (decision) => {
|
|
1991
|
+
const sessionId = decision.session_id;
|
|
1992
|
+
if (!sessionId)
|
|
1993
|
+
return;
|
|
1994
|
+
if (decision.level === 'block') {
|
|
1995
|
+
writeStatus({
|
|
1996
|
+
type: 'execution-status',
|
|
1997
|
+
status: 'failed',
|
|
1998
|
+
sessionId,
|
|
1999
|
+
timestamp: decision.timestamp ?? Date.now(),
|
|
2000
|
+
error: decision.reason ?? null,
|
|
2001
|
+
});
|
|
2002
|
+
}
|
|
2003
|
+
};
|
|
2004
|
+
storage.on('event', onEvent);
|
|
2005
|
+
storage.on('tool-event', onToolEvent);
|
|
2006
|
+
storage.on('decision', onDecision);
|
|
2007
|
+
req.on('close', () => {
|
|
2008
|
+
storage.removeListener('event', onEvent);
|
|
2009
|
+
storage.removeListener('tool-event', onToolEvent);
|
|
2010
|
+
storage.removeListener('decision', onDecision);
|
|
2011
|
+
});
|
|
2012
|
+
});
|
|
1302
2013
|
// SSE: real-time event stream
|
|
1303
2014
|
this.app.get('/api/events/stream', (req, res) => {
|
|
1304
2015
|
res.writeHead(200, {
|